zenvault
Developers

REST, JSON, and a workspace API key.

No SDK ceremony. Every endpoint speaks curl. Scopes are enforced per route. Webhook signatures verify against raw bytes.

Auth

Workspace API keys, scoped.

Each workspace issues one or more API keys. Each key carries an explicit scope list. A key missing a required scope returns 403, not 401.

accounts:readaccounts:write
transfers:readtransfers:write
addresses:readaddresses:write
balances:read
external-wallets:readexternal-wallets:write
webhook-endpoints:readwebhook-endpoints:write
curl
# Issue a transfer
curl -X POST https://api.zenvault.io/api/v1/transfers/external \
  -H "Authorization: Bearer zv_a1b2c3d4_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "src_account_id":   "acc_7a2b1c3e-…",
    "destination":      { "external_wallet_id": "wlt_4f8a-…" },
    "asset_canonical":  "USDT_ETHEREUM",
    "amount":           "1500.00"
  }'
Transfers

Four kinds. One state machine.

Internal (vault to vault), external (to an allowlisted wallet or a one-time address), contract call, ERC-20 approve. All four transition through the same states and emit the same webhook events.

endpoints
POST /v1/transfers/internal
POST /v1/transfers/external
POST /v1/transfers/contract-call
POST /v1/transfers/approve

GET  /v1/transfers
GET  /v1/transfers/:id
POST /v1/transfers/:id/refresh
response
{
  "id":                  "txr_91a2b3c4-…",
  "workspace_id":        "wks_a3c4-…",
  "kind":                "external",
  "state":               "broadcasting",
  "asset_canonical":     "USDT_ETHEREUM",
  "amount":              "1500.00",
  "src_account_id":      "acc_7a2b1c3e-…",
  "dest_external_wallet_id": "wlt_4f8a-…",
  "provider_tx_ref":     "0xabc…",
  "idempotency_key":     "f93b6c5a-…",
  "created_at":          "2026-05-25T11:42:08Z",
  "updated_at":          "2026-05-25T11:42:14Z"
}
Webhooks

Signed deliveries to your endpoint.

Every transfer state change emits a delivery. The signature is HMAC-SHA256 over <ts>.<body> with the plaintext secret you received once at endpoint creation. Retries follow capped exponential backoff with jitter for roughly three days.

headers
zenvault-event-id:         evt_a1b2c3-…
zenvault-event-type:       transfer.completed
zenvault-delivery-attempt: 1
zenvault-signature:        t=1748400123,v1=4a1f…
verify
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(req, secret) {
  const sig = req.headers["zenvault-signature"];
  const [tsPart, v1Part] = sig.split(",");
  const ts = tsPart.replace("t=", "");
  const v1 = v1Part.replace("v1=", "");

  const expected = createHmac("sha256", secret)
    .update(`${ts}.${req.rawBody}`)
    .digest("hex");

  return timingSafeEqual(
    Buffer.from(v1, "hex"),
    Buffer.from(expected, "hex"),
  );
}
Idempotency

Retries don't double-send.

Every mutating endpoint accepts an Idempotency-Key header. The first call commits; concurrent calls with the same key return the same response. If a unique-constraint race fires, you get the original response body — never a 409 for a key you correctly retried.