The surface every guide assumes
| What you want | Python | Node | Go |
|---|---|---|---|
| Act for a client, or for yourself | hevn.acting_as(client_id) | hevn.actingAs(clientId) | api.ActingAs(clientID) |
| Read | hevn.get(path, params={…}) | hevn.get(path, {…}) | api.Get(path, hevn.Query{…}) |
| Write | hevn.post(path, body, idempotency_key="…") | hevn.post(path, body, { idempotencyKey }) | api.Post(path, hevn.Body{…}, hevn.IdempotencyKey("…")) |
| Read a response field | payout["approval"]["payload"] | payout.approval.payload | payout.Str("approval.payload") |
| Walk a response list | page["items"] | page.items | page.List("items") |
| Sign an approval | key.sign_payload(b64) | key.signPayload(b64) | key.SignPayload(b64) |
| Name the signing key | key.public_key | key.publicKey | key.PublicKey |
| Confirm to settlement | hevn.confirm_until_settled(path, sig) | hevn.confirmUntilSettled(path, sig) | api.ConfirmUntilSettled(path, sig) |
| Poll a read | poll_until(read, done) | pollUntil(read, done) | hevn.PollUntil(read, done, deadline) |
| Download an export | hevn.download(path, params, into) | hevn.download(path, params, into) | api.Download(path, hevn.Query{…}, into) |
HEVN_API, HEVN_KEY_PEM and HEVN_EMAIL — set up in the
Quickstart. Nothing below hardcodes a host. HEVN_API ends in /dapi/v1
(https://sandbox-api.hevn.finance/dapi/v1); the old /v1 base answers 404 rather than redirecting, so a helper
that asserts the suffix at start-up turns a whole run of mystery 404s into one clear failure.
In the Go tabs the client is a variable called
api and hevn is the package: api.Post(…) is a
call on your client, hevn.Body{…} is a type from the package. Naming the variable hevn shadows
the package and stops the file compiling.Binding an account
acting_as is the only thing that sets X-Hevn-Account, and three kinds of route read it differently:
| Routes | The header |
|---|---|
/banks*, /payins*, /payouts*, /contacts*, /transactions*, /documents*, /sandbox/* | optional — bound client, or your own account when unbound |
/client, /client/balance, /client/kyb, /client/kyb/complete | required — the header is the only selector |
POST /clients, GET /clients, every /escrow* | refused — 400 account_scope_conflict |
cl_… too — the userId the login answered:
me = hevn.acting_as(os.environ["HEVN_USER_ID"]) # the userId login answered
mine = me.get("/client/balance")
const me = hevn.actingAs(process.env.HEVN_USER_ID); // the userId login answered
const mine = await me.get("/client/balance");
me := api.ActingAs(os.Getenv("HEVN_USER_ID")) // the userId login answered
mine, err := me.Get("/client/balance")
/client* sends no header at all, which is a 422 validation_failed with
details.location: "header" and X-Hevn-Account in details.fields — not a read of your own account. A bound client
calling POST /clients or any escrow route is the mirror image, 400 account_scope_conflict with
details.reason: "selfScopedRoute", so keep the unbound client for those.
Install
python -m venv .venv && source .venv/bin/activate
pip install cryptography httpx
# Node 20 or newer. Signing and HTTP use node:crypto and global fetch.
node --version
npm init --yes
go mod init example.com/hevn
# Signing and HTTP use the standard library only: crypto/ecdsa, encoding/pem, net/http.
Load the developer key
The private half comes fromHEVN_KEY_PEM and never leaves the process.
import base64, os
from cryptography.hazmat.primitives import serialization
def load_key():
with open(os.environ["HEVN_KEY_PEM"], "rb") as handle:
return serialization.load_pem_private_key(handle.read(), password=None)
def public_key_b64(key) -> str:
spki = key.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
return base64.b64encode(spki).decode()
import { createPrivateKey, createPublicKey } from "node:crypto";
import { readFileSync } from "node:fs";
export function loadKey() {
return createPrivateKey(readFileSync(process.env.HEVN_KEY_PEM));
}
export function publicKeyB64(key) {
return createPublicKey(key).export({ type: "spki", format: "der" }).toString("base64");
}
func LoadKey() (*ecdsa.PrivateKey, error) {
armored, err := os.ReadFile(os.Getenv("HEVN_KEY_PEM"))
if err != nil {
return nil, err
}
block, _ := pem.Decode(armored)
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
key, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, errors.New("hevn: not a P-256 key")
}
return key, nil
}
func PublicKeyB64(key *ecdsa.PrivateKey) (string, error) {
spki, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(spki), nil
}
Sign bytes
Two functions, used by login and by every payment: ECDSA P-256 over SHA-256, DER-encoded, base64 out. Signing is why each rule is there.import base64
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
def sign_bytes(key, message: bytes) -> str:
"""base64(DER ECDSA P-256 / SHA-256) over exactly these bytes."""
return base64.b64encode(key.sign(message, ec.ECDSA(hashes.SHA256()))).decode()
def sign_payload(key, payload_b64: str) -> str:
return sign_bytes(key, base64.b64decode(payload_b64))
import { sign } from "node:crypto";
export function signBytes(key, message) {
return sign("sha256", message, { key, dsaEncoding: "der" }).toString("base64");
}
export function signPayload(key, payloadB64) {
return signBytes(key, Buffer.from(payloadB64, "base64"));
}
func SignBytes(key *ecdsa.PrivateKey, message []byte) (string, error) {
digest := sha256.Sum256(message)
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(der), nil
}
func SignPayload(key *ecdsa.PrivateKey, payloadB64 string) (string, error) {
message, err := base64.StdEncoding.DecodeString(payloadB64)
if err != nil {
return "", err
}
return SignBytes(key, message)
}
Wrap the key
The guides callkey.sign_payload(...) and read key.public_key. That is these two functions
bound to one loaded key.
class Key:
def __init__(self):
self.raw = load_key() # the loaded key login() takes
self.public_key = public_key_b64(self.raw)
def sign_bytes(self, message: bytes) -> str:
return sign_bytes(self.raw, message)
def sign_payload(self, payload_b64: str) -> str:
return sign_payload(self.raw, payload_b64)
export class Key {
constructor() {
this.raw = loadKey(); // the loaded key login() takes
this.publicKey = publicKeyB64(this.raw);
}
signBytes(message) {
return signBytes(this.raw, message);
}
signPayload(payloadB64) {
return signPayload(this.raw, payloadB64);
}
}
type Key struct {
raw *ecdsa.PrivateKey // the loaded key Login() takes
PublicKey string
}
func NewKey() (*Key, error) {
key, err := LoadKey()
if err != nil {
return nil, err
}
public, err := PublicKeyB64(key)
return &Key{raw: key, PublicKey: public}, err
}
func (k *Key) SignBytes(message []byte) (string, error) { return SignBytes(k.raw, message) }
func (k *Key) SignPayload(payloadB64 string) (string, error) {
return SignPayload(k.raw, payloadB64)
}
Log in
Two calls, two signatures. The result is an access token good for an hour and a refresh token good for sixty days — see Sessions.import os, secrets, time, httpx
def login(key) -> dict:
api, email = os.environ["HEVN_API"], os.environ["HEVN_EMAIL"]
nonce = secrets.randbits(48)
expiry = int(time.time() * 1000) + 120_000
proof = f"hevn-developer-key-login:{email}:{nonce}:{expiry}".encode()
started = httpx.post(f"{api}/auth/challenge", json={
"email": email,
"publicKey": public_key_b64(key),
"nonce": nonce,
"requestExpiry": expiry,
"signature": sign_bytes(key, proof),
})
started.raise_for_status()
challenge = started.json()
minted = httpx.post(f"{api}/auth/token", json={
"challengeId": challenge["challengeId"],
"signature": sign_payload(key, challenge["payload"]),
})
minted.raise_for_status()
return minted.json() # accessToken, refreshToken, expiresIn, userId
export async function login(key) {
const api = process.env.HEVN_API;
const email = process.env.HEVN_EMAIL;
const nonce = Number(process.hrtime.bigint() % 281474976710656n);
const expiry = Date.now() + 120_000;
const proof = Buffer.from(`hevn-developer-key-login:${email}:${nonce}:${expiry}`);
const post = async (path, body) => {
const response = await fetch(`${api}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`${path} ${response.status} ${await response.text()}`);
return response.json();
};
const challenge = await post("/auth/challenge", {
email,
publicKey: publicKeyB64(key),
nonce,
requestExpiry: expiry,
signature: signBytes(key, proof),
});
return post("/auth/token", {
challengeId: challenge.challengeId,
signature: signPayload(key, challenge.payload),
});
}
func Login(key *ecdsa.PrivateKey) (Tokens, error) {
api, email := os.Getenv("HEVN_API"), os.Getenv("HEVN_EMAIL")
nonce := rand.Int63n(1 << 48)
expiry := time.Now().Add(2 * time.Minute).UnixMilli()
proof := fmt.Sprintf("hevn-developer-key-login:%s:%d:%d", email, nonce, expiry)
proofSignature, err := SignBytes(key, []byte(proof))
if err != nil {
return Tokens{}, err
}
publicKey, err := PublicKeyB64(key)
if err != nil {
return Tokens{}, err
}
var challenge struct{ ChallengeID, Payload string }
if err := PostJSON(api+"/auth/challenge", map[string]any{
"email": email, "publicKey": publicKey, "nonce": nonce,
"requestExpiry": expiry, "signature": proofSignature,
}, &challenge); err != nil {
return Tokens{}, err
}
payloadSignature, err := SignPayload(key, challenge.Payload)
if err != nil {
return Tokens{}, err
}
var tokens Tokens
err = PostJSON(api+"/auth/token", map[string]any{
"challengeId": challenge.ChallengeID, "signature": payloadSignature,
}, &tokens)
return tokens, err
}
Hold the session
One object owns the tokens and re-mints the access token on demand, so the request layer below never thinks about expiry.class Session:
def __init__(self, key):
self.key = key
self._tokens, self._expires_at = None, 0.0
def token(self, refresh: bool = False) -> str:
if refresh or self._tokens is None or time.time() > self._expires_at - 60:
self._tokens = login(self.key.raw) if self._tokens is None else self._refresh()
self._expires_at = time.time() + self._tokens["expiresIn"]
return self._tokens["accessToken"]
def _refresh(self) -> dict:
response = httpx.post(
f"{os.environ['HEVN_API']}/auth/refresh",
json={},
headers={"Authorization": f"Bearer {self._tokens['refreshToken']}"},
)
response.raise_for_status()
return {**self._tokens, **response.json()}
export class Session {
constructor(key) {
this.key = key;
this.tokens = null;
this.expiresAt = 0;
}
async token(refresh = false) {
if (refresh || !this.tokens || Date.now() > this.expiresAt - 60_000) {
this.tokens = this.tokens ? await this.refresh() : await login(this.key.raw);
this.expiresAt = Date.now() + this.tokens.expiresIn * 1000;
}
return this.tokens.accessToken;
}
async refresh() {
const response = await fetch(`${process.env.HEVN_API}/auth/refresh`, {
method: "POST",
headers: {
authorization: `Bearer ${this.tokens.refreshToken}`,
"content-type": "application/json",
},
body: "{}",
});
if (!response.ok) throw new Error(`refresh ${response.status}`);
return { ...this.tokens, ...(await response.json()) };
}
}
// Tokens is what both /auth/token and /auth/refresh answer; the refresh call
// omits refreshToken, so the zero value keeps the one you already hold.
type Tokens struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int `json:"expiresIn"`
UserID string `json:"userId"`
}
type Session struct {
Key *Key
tokens Tokens
expiresAt time.Time
mu sync.Mutex
}
func (s *Session) Token(refresh bool) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !refresh && s.tokens.AccessToken != "" && time.Now().Before(s.expiresAt.Add(-time.Minute)) {
return s.tokens.AccessToken, nil
}
tokens, err := s.mint()
if err != nil {
return "", err
}
s.tokens = tokens
s.expiresAt = time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
return s.tokens.AccessToken, nil
}
func (s *Session) mint() (Tokens, error) {
if s.tokens.RefreshToken == "" {
return Login(s.Key.raw)
}
var minted Tokens
err := PostJSON(os.Getenv("HEVN_API")+"/auth/refresh", map[string]any{},
&minted, Bearer(s.tokens.RefreshToken))
minted.RefreshToken = s.tokens.RefreshToken // the refresh call does not re-issue it
return minted, err
}
// PostJSON POSTs a JSON body and decodes the answer; opts set headers such as
// the bearer. Login (above) uses it without a bearer, because neither login
// call takes one.
func PostJSON(url string, body map[string]any, out any, opts ...func(*http.Request)) error {
encoded, err := json.Marshal(body)
if err != nil {
return err
}
request, err := http.NewRequest("POST", url, bytes.NewReader(encoded))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
for _, apply := range opts {
apply(request)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
payload, _ := decode(response)
return asAPIError(response.StatusCode, payload)
}
return json.NewDecoder(response.Body).Decode(out)
}
func Bearer(token string) func(*http.Request) {
return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }
}
Send a request
One place turns the error envelope into a typed error, honoursRetry-After on a 429, re-mints once on a 401, and adds X-Hevn-Account when the client is
bound.
import os, time, httpx
class HevnError(Exception):
def __init__(self, status: int, body: dict):
error = (body or {}).get("error", {})
self.status, self.code = status, error.get("code", "internal_error")
self.details = error.get("details") or {}
super().__init__(f"{status} {self.code}: {error.get('message', '')}")
class Hevn:
def __init__(self, session, client_id=None, http=None):
self.session, self.client_id = session, client_id
self.http = http or httpx.Client(base_url=os.environ["HEVN_API"], timeout=30)
def acting_as(self, client_id):
"""A view of the same session that sends X-Hevn-Account on every call."""
return Hevn(self.session, client_id, self.http)
def get(self, path, params=None):
return self.call("GET", path, params=params)
def post(self, path, body=None, *, idempotency_key=None):
return self.call("POST", path, body=body, idempotency_key=idempotency_key)
def put(self, path, body=None, *, idempotency_key=None):
return self.call("PUT", path, body=body, idempotency_key=idempotency_key)
def patch(self, path, body=None):
return self.call("PATCH", path, body=body)
def delete(self, path):
return self.call("DELETE", path)
def call(self, method, path, *, body=None, params=None, idempotency_key=None, retried=False):
headers = {"Authorization": f"Bearer {self.session.token()}"}
if self.client_id:
headers["X-Hevn-Account"] = self.client_id
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
again = dict(body=body, params=params, idempotency_key=idempotency_key)
response = self.http.request(method, path, json=body, params=params, headers=headers)
if response.status_code == 429:
time.sleep(float(response.headers.get("Retry-After", "1")))
return self.call(method, path, retried=retried, **again)
if response.status_code == 401 and not retried:
self.session.token(refresh=True)
return self.call(method, path, retried=True, **again)
payload = response.json() if response.content else {}
if response.status_code >= 400:
raise HevnError(response.status_code, payload)
return payload
export class HevnError extends Error {
constructor(status, body) {
const error = body?.error ?? {};
super(`${status} ${error.code ?? "internal_error"}: ${error.message ?? ""}`);
this.status = status;
this.code = error.code ?? "internal_error";
this.details = error.details ?? {};
}
}
export class Hevn {
constructor(session, clientId) {
this.session = session;
this.clientId = clientId;
this.api = process.env.HEVN_API;
}
actingAs(clientId) {
return new Hevn(this.session, clientId);
}
get(path, params) {
return this.call("GET", path, { params });
}
post(path, body, opts) {
return this.call("POST", path, { body, ...opts });
}
put(path, body, opts) {
return this.call("PUT", path, { body, ...opts });
}
patch(path, body) {
return this.call("PATCH", path, { body });
}
delete(path) {
return this.call("DELETE", path);
}
async call(method, path, { body, params, idempotencyKey, retried = false } = {}) {
const url = new URL(this.api + path);
for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, String(v));
const headers = { authorization: `Bearer ${await this.session.token()}` };
if (body) headers["content-type"] = "application/json";
if (this.clientId) headers["x-hevn-account"] = this.clientId;
if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
const response = await fetch(url, { method, headers, body: body && JSON.stringify(body) });
const again = { body, params, idempotencyKey, retried };
if (response.status === 429) {
const wait = Number(response.headers.get("retry-after") ?? 1) * 1000;
await new Promise((done) => setTimeout(done, wait));
return this.call(method, path, again);
}
if (response.status === 401 && !retried) {
await this.session.token(true);
return this.call(method, path, { ...again, retried: true });
}
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (!response.ok) throw new HevnError(response.status, payload);
return payload;
}
}
type APIError struct {
Status int
Code string
Message string
Details map[string]any
}
func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
// Body is a request body, Query a query string, Payload a decoded response.
// Payload.Str walks a dotted path: payout.Str("approval.payload").
type (
Body map[string]any
Query map[string]string
Payload map[string]any
)
// ActingAs returns a view of the same session that sends X-Hevn-Account.
func (c *Client) ActingAs(clientID string) *Client {
bound := *c
bound.ClientID = clientID
return &bound
}
func (c *Client) Get(path string, query ...Query) (Payload, error) {
return c.Call("GET", path, nil, Opts{Query: first(query)})
}
func (c *Client) Post(path string, body Body, opts ...Opt) (Payload, error) {
return c.Call("POST", path, body, options(opts))
}
func (c *Client) Put(path string, body Body, opts ...Opt) (Payload, error) {
return c.Call("PUT", path, body, options(opts))
}
func (c *Client) Patch(path string, body Body, opts ...Opt) (Payload, error) {
return c.Call("PATCH", path, body, options(opts))
}
func (c *Client) Delete(path string, opts ...Opt) (Payload, error) {
return c.Call("DELETE", path, nil, options(opts))
}
func (c *Client) Call(method, path string, body Body, o Opts) (Payload, error) {
for attempt := 0; ; attempt++ {
res, err := c.HTTP.Do(c.request(method, path, body, o))
if err != nil {
return nil, err
}
payload, status := decode(res)
switch {
case status == 429 && attempt < 3:
time.Sleep(retryAfter(res))
case status == 401 && attempt == 0:
if _, err := c.Session.Token(true); err != nil {
return nil, err
}
case status >= 400:
return nil, asAPIError(status, payload)
default:
return payload, nil
}
}
}
Download an export
GET /dapi/v1/transactions/export streams a file rather than JSON, so it gets its own method: same
headers, same typed error, bytes to disk instead of a parsed body.
def download(self, path, params, into):
"""A method on Hevn. An export streams a file; every other route answers JSON."""
headers = {"Authorization": f"Bearer {self.session.token()}"}
if self.client_id:
headers["X-Hevn-Account"] = self.client_id
with self.http.stream("GET", path, params=params, headers=headers) as response:
if response.status_code >= 400:
response.read()
raise HevnError(response.status_code, response.json())
with open(into, "wb") as file:
for chunk in response.iter_bytes():
file.write(chunk)
return into
// A method on Hevn. An export streams a file; every other route answers JSON.
export async function download(path, params, into) {
const url = new URL(this.api + path);
for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, String(v));
const headers = { authorization: `Bearer ${await this.session.token()}` };
if (this.clientId) headers["x-hevn-account"] = this.clientId;
const response = await fetch(url, { headers });
if (!response.ok) throw new HevnError(response.status, await response.json());
await writeFile(into, Buffer.from(await response.arrayBuffer()));
return into;
}
// Download streams an export to a file; every other route answers JSON.
func (c *Client) Download(path string, query Query, into string) (string, error) {
res, err := c.HTTP.Do(c.request("GET", path, nil, Opts{Query: query}))
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
payload, status := decode(res)
return "", asAPIError(status, payload)
}
file, err := os.Create(into)
if err != nil {
return "", err
}
defer file.Close()
_, err = io.Copy(file, res.Body)
return into, err
}
Confirm until it settles
The whole retry policy as one method:already_funded is
success, funding_attempt_expired means re-open, a 202 and the bundler codes mean confirm again,
anything else propagates.
CONFIRM_AGAIN = {"funding_in_progress", "bundler_rejected", "bundler_unavailable"}
class ReopenRequired(Exception):
"""The approval expired. Re-open the payment with the same Idempotency-Key."""
def confirm_until_settled(self, confirm_path: str, signature: str, *, deadline=180.0):
"""A method on Hevn. confirm_path is "/payouts/po_…/confirm" or an escrow action's confirm."""
started, delay = time.monotonic(), 1.0
while True:
try:
receipt = self.post(confirm_path, {"signature": signature})
if receipt["status"] != "submitted":
return receipt
reason = "submitted"
except HevnError as error:
if error.code == "already_funded":
return {"status": "settled", "transactionHash": error.details.get("transactionHash")}
if error.code == "funding_attempt_expired":
raise ReopenRequired(confirm_path) from error
if error.code not in CONFIRM_AGAIN:
raise
reason = error.code
if time.monotonic() - started > deadline:
raise TimeoutError(f"{confirm_path} still {reason} after {deadline:.0f}s")
time.sleep(delay)
delay = min(delay * 2, 8.0)
const CONFIRM_AGAIN = new Set(["funding_in_progress", "bundler_rejected", "bundler_unavailable"]);
export class ReopenRequired extends Error {}
// A method on Hevn. confirmPath is "/payouts/po_…/confirm" or an escrow action's confirm.
export async function confirmUntilSettled(confirmPath, signature, { deadline = 180_000 } = {}) {
const until = Date.now() + deadline;
let delay = 1_000;
for (;;) {
let reason = "submitted";
try {
const receipt = await this.post(confirmPath, { signature });
if (receipt.status !== "submitted") return receipt;
} catch (error) {
if (error.code === "already_funded") {
return { status: "settled", transactionHash: error.details.transactionHash };
}
if (error.code === "funding_attempt_expired") throw new ReopenRequired(confirmPath);
if (!CONFIRM_AGAIN.has(error.code)) throw error;
reason = error.code;
}
if (Date.now() > until) throw new Error(`${confirmPath} still ${reason}`);
await new Promise((wake) => setTimeout(wake, delay));
delay = Math.min(delay * 2, 8_000);
}
}
var confirmAgain = map[string]bool{
"funding_in_progress": true, "bundler_rejected": true, "bundler_unavailable": true,
}
var ErrReopenRequired = errors.New("hevn: approval expired, re-open with the same Idempotency-Key")
// confirmPath is "/payouts/po_…/confirm" or an escrow action's confirm.
func (c *Client) ConfirmUntilSettled(confirmPath, signature string) (Payload, error) {
until, delay := time.Now().Add(3*time.Minute), time.Second
for {
receipt, err := c.Post(confirmPath, Body{"signature": signature})
var apiErr *APIError
switch {
case err == nil && receipt.Str("status") != "submitted":
return receipt, nil
case err == nil: // submitted: no receipt yet, confirm again
case !errors.As(err, &apiErr):
return nil, err
case apiErr.Code == "already_funded":
return Payload{"status": "settled", "transactionHash": apiErr.Details["transactionHash"]}, nil
case apiErr.Code == "funding_attempt_expired":
return nil, ErrReopenRequired
case !confirmAgain[apiErr.Code]:
return nil, err
}
if time.Now().After(until) {
return nil, fmt.Errorf("hevn: %s did not settle in time", confirmPath)
}
time.Sleep(delay)
if delay < 8*time.Second {
delay *= 2
}
}
}
Poll a read
Client provisioning, KYB review, rail activation and payin settlement all end in a read that changes on someone else’s clock.import random, time
def poll_until(read, done, *, deadline=120.0, first=1.0, cap=8.0):
"""Call read() until done(value) is true. Returns the last value or raises TimeoutError."""
started, delay = time.monotonic(), first
while True:
value = read()
if done(value):
return value
if time.monotonic() - started > deadline:
raise TimeoutError(f"still {value.get('status')} after {deadline:.0f}s")
time.sleep(delay + random.uniform(0, delay / 2))
delay = min(delay * 2, cap)
export async function pollUntil(read, done, { deadline = 120_000, first = 1_000, cap = 8_000 } = {}) {
const until = Date.now() + deadline;
let delay = first;
for (;;) {
const value = await read();
if (done(value)) return value;
if (Date.now() > until) throw new Error(`still ${value.status} after ${deadline}ms`);
await new Promise((wake) => setTimeout(wake, delay + Math.random() * (delay / 2)));
delay = Math.min(delay * 2, cap);
}
}
func PollUntil[T any](read func() (T, error), done func(T) bool, deadline time.Duration) (T, error) {
until, delay := time.Now().Add(deadline), time.Second
for {
value, err := read()
if err != nil {
return value, err
}
if done(value) {
return value, nil
}
if time.Now().After(until) {
return value, fmt.Errorf("hevn: still pending after %s", deadline)
}
time.Sleep(delay + time.Duration(rand.Int63n(int64(delay/2))))
if delay < 8*time.Second {
delay *= 2
}
}
}
The Go plumbing
Python and Node are complete above. Go’s helpers are worth spelling out once.Go
type Client struct {
API string
ClientID string
HTTP *http.Client
Session *Session
}
type Opts struct {
Query Query
IdempotencyKey string
}
type Opt func(*Opts)
func IdempotencyKey(key string) Opt { return func(o *Opts) { o.IdempotencyKey = key } }
func options(opts []Opt) Opts {
var o Opts
for _, apply := range opts {
apply(&o)
}
return o
}
func first(queries []Query) Query {
if len(queries) == 0 {
return nil
}
return queries[0]
}
// at walks a dotted path and returns nil when any step is missing.
func (p Payload) at(path string) any {
var node any = map[string]any(p)
for _, step := range strings.Split(path, ".") {
object, ok := node.(map[string]any)
if !ok {
return nil
}
node = object[step]
}
return node
}
func (p Payload) Str(path string) string { value, _ := p.at(path).(string); return value }
func (p Payload) Bool(path string) bool { value, _ := p.at(path).(bool); return value }
// List walks a dotted path to an array of objects: catalogue.List("rails").
func (p Payload) List(path string) []Payload {
items, _ := p.at(path).([]any)
page := make([]Payload, 0, len(items))
for _, item := range items {
if object, ok := item.(map[string]any); ok {
page = append(page, object)
}
}
return page
}
func (c *Client) request(method, path string, body Body, o Opts) *http.Request {
var reader io.Reader
if body != nil {
encoded, _ := json.Marshal(body)
reader = bytes.NewReader(encoded)
}
request, _ := http.NewRequest(method, c.API+path, reader)
token, _ := c.Session.Token(false)
request.Header.Set("Authorization", "Bearer "+token)
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if c.ClientID != "" {
request.Header.Set("X-Hevn-Account", c.ClientID)
}
if o.IdempotencyKey != "" {
request.Header.Set("Idempotency-Key", o.IdempotencyKey)
}
if len(o.Query) > 0 {
query := request.URL.Query()
for name, value := range o.Query {
query.Set(name, value)
}
request.URL.RawQuery = query.Encode()
}
return request
}
func decode(res *http.Response) (Payload, int) {
defer res.Body.Close()
var payload Payload
_ = json.NewDecoder(res.Body).Decode(&payload)
return payload, res.StatusCode
}
func retryAfter(res *http.Response) time.Duration {
seconds, err := strconv.Atoi(res.Header.Get("Retry-After"))
if err != nil || seconds <= 0 {
return time.Second
}
return time.Duration(seconds) * time.Second
}
func asAPIError(status int, payload Payload) *APIError {
body, _ := payload["error"].(map[string]any)
details, _ := body["details"].(map[string]any)
code, _ := body["code"].(string)
message, _ := body["message"].(string)
if code == "" {
code = "internal_error"
}
return &APIError{Status: status, Code: code, Message: message, Details: details}
}
Put it together
key = Key()
hevn = Hevn(Session(key)) # unbound: /clients and /escrow
northwind = hevn.acting_as("cl_7YQ2Kf3mN8") # bound to one client
me = hevn.acting_as(os.environ["HEVN_USER_ID"]) # bound to your own account, for /client*
const key = new Key();
const hevn = new Hevn(new Session(key)); // unbound: /clients and /escrow
const northwind = hevn.actingAs("cl_7YQ2Kf3mN8"); // bound to one client
const me = hevn.actingAs(process.env.HEVN_USER_ID); // bound to your own account
key, err := hevn.NewKey()
api := &hevn.Client{API: os.Getenv("HEVN_API"), HTTP: http.DefaultClient, Session: &hevn.Session{Key: key}}
northwind := api.ActingAs("cl_7YQ2Kf3mN8")
me := api.ActingAs(os.Getenv("HEVN_USER_ID"))
Back to the Quickstart
Nine steps in the sandbox, from an empty account to money that moved on chain.