This guide covers advanced usage and patterns for the GuildPass SDK.
As the SDK gains support for more options, configuring the GuildPassClient directly with an object can become complex. The GuildPassClientBuilder provides a fluent, strongly typed way to build and validate your client configuration before runtime.
import { GuildPassClientBuilder } from "@guildpass/sdk";
// Validate and build fluently
const client = new GuildPassClientBuilder('https://api.guildpass.xyz')
.withApiKey(process.env.GUILDPASS_API_KEY)
.withTimeout(5000)
.withRetry({ maxRetries: 3 })
.build();The builder validates your configuration when .build() is called, immediately throwing a GuildPassConfigError if any settings are invalid. Existing configuration methods (passing an object directly to the constructor) continue to work exactly as before.
The SDK uses a custom GuildPassError class. You should always wrap SDK calls in try-catch blocks.
import { GuildPassClient, GuildPassErrorCode } from "@guildpass/sdk";
try {
await client.access.checkAccess({...});
} catch (error) {
if (error instanceof GuildPassError) {
switch (error.code) {
case GuildPassErrorCode.UNAUTHORISED:
// Handle invalid API key
break;
case GuildPassErrorCode.NOT_FOUND:
// Handle missing guild or resource
break;
case GuildPassErrorCode.TIMEOUT:
// Handle network timeout
break;
case GuildPassErrorCode.INVALID_RESPONSE:
// Handle a malformed or unexpected non-JSON API response
break;
}
}
}Every SDK error extends GuildPassError, so a single instanceof GuildPassError
catch still works. On top of that, HTTP failures come back as subclasses of
GuildPassApiError keyed to the status code, which reads better than comparing
error.status yourself:
import {
GuildPassAuthenticationError, // 401: missing/invalid credentials
GuildPassAuthorizationError, // 403: authenticated but not allowed
GuildPassValidationError, // 400/422: server rejected the request payload
GuildPassRateLimitError, // 429: slow down, see retryAfterMs
GuildPassServerError, // 5xx: server-side failure, retrying is reasonable
GuildPassTimeoutError, // no response within the configured timeoutMs
GuildPassCancellationError, // the caller's AbortSignal fired
} from "@guildpass/sdk";
try {
await client.access.checkAccess({...});
} catch (error) {
if (error instanceof GuildPassRateLimitError) {
// The server told us how long to wait.
await sleep(error.retryAfterMs ?? 1000);
// ... retry
} else if (error instanceof GuildPassAuthenticationError) {
// Refresh credentials, then retry.
} else if (error instanceof GuildPassAuthorizationError) {
// Don't retry: the account simply isn't allowed. Surface this to the user.
} else if (error instanceof GuildPassServerError || error instanceof GuildPassTimeoutError) {
// Transient: safe to retry with backoff.
} else if (error instanceof GuildPassCancellationError) {
// We cancelled this ourselves; usually nothing to do.
}
}Errors that received an HTTP response also carry requestMeta (request ID,
correlation ID, trace ID, duration) for support tickets and log correlation.
Cross-realm or duplicated-module setups (monorepos, vm contexts) where
instanceof fails can use the isGuildPassError type guard instead.
You can cancel in-flight requests using an AbortSignal. This is useful for UI unmounting, manual cancellation, or server-side request propagation.
const controller = new AbortController();
// Cancel after 2 seconds
setTimeout(() => controller.abort(), 2000);
try {
await client.access.checkAccess({
address: '0x...',
guildId: '...',
}, { signal: controller.signal });
} catch (error) {
if (error.code === GuildPassErrorCode.REQUEST_CANCELLED) {
console.log('Request was cancelled by the user');
}
}The SDK works in Node.js 18+. If you are on an older version, you may need to polyfill fetch.
The SDK is tree-shakeable and optimized for modern browsers. It does not include any Node-only dependencies.
client.getConfig() returns a public snapshot of the SDK configuration for
debugging and diagnostics, but sensitive values are omitted. For example,
apiKey is not returned even when the client was constructed with one:
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
apiKey: process.env.GUILDPASS_API_KEY,
});
const config = client.getConfig();
console.log(config.apiUrl); // https://api.guildpass.xyz
console.log(config.apiKey); // undefinedThe SDK keeps the real API key internally and continues to use it for authenticated requests. Avoid logging the original constructor config object directly if it contains secrets.
The SDK can attach lightweight metadata headers to API requests, helping backend services identify the SDK version, runtime, and integration source during debugging and support.
By default, every GuildPass API-relative request includes an
X-GuildPass-SDK-Version header with the bundled SDK version:
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
apiKey: process.env.GUILDPASS_API_KEY,
});
// Requests automatically include:
// X-GuildPass-SDK-Version: 0.1.0
await client.guilds.getGuild({ guildId: 'prime-guild' });Set clientName and clientVersion to identify your integration in the
X-GuildPass-Client header:
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
clientName: 'my-dapp',
clientVersion: '2.1.0',
});
// Requests include:
// X-GuildPass-SDK-Version: 0.1.0
// X-GuildPass-Client: my-dapp/2.1.0
await client.access.checkAccess({ ... });When clientVersion is omitted, only the client name is sent. When only
clientVersion is provided, it is sent alone.
Set sendClientMetadata: false to suppress all metadata headers:
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
sendClientMetadata: false,
});
// No X-GuildPass-* headers are attached.
await client.roles.getRoles({ guildId: 'guild-1' });- Metadata headers are only sent to GuildPass API-relative requests. External
absolute URLs (e.g., custom RPC endpoints) never receive
X-GuildPass-*headers. Similarly, theX-API-Keyheader is never sent to external URLs. - Metadata headers never include API keys, wallet secrets, or tokens. The header values only contain the SDK version and the consumer-provided client name/version strings.
- Client name and version are public by design. Use generic identifiers if you prefer not to expose specific application names in network logs.
- Configuration is inspectable.
client.getConfig()returnsclientName,clientVersion, andsendClientMetadata(non-sensitive by design).
The SDK automatically normalizes addresses to lowercase for consistency and accepts both lowercase and mixed-case addresses by default. You can also use the exported utilities to format or strictly validate EIP-55 checksum addresses:
import {
normaliseAddress,
toChecksumAddress,
isChecksumAddress,
validateAddress,
} from '@guildpass/sdk';
// Convert to lowercase
const clean = normaliseAddress('0xabc...');
// Convert to EIP-55 Checksum
const checksummed = toChecksumAddress('0xabc...');
// Check if an address has a valid checksum
const isValid = isChecksumAddress('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); // true
// Strict validation mode (throws if checksum is invalid)
validateAddress('0xd8da...', { strict: true });To support multiple GuildPass deployments across different networks, pass a chains map keyed by chain ID. Each entry can specify an rpcUrl and contractAddress for that chain.
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
chainId: 8453, // default chain
chains: {
1: {
rpcUrl: 'https://eth-mainnet.example.com',
contractAddress: '0xYourEthContract',
},
8453: {
rpcUrl: 'https://base-mainnet.example.com',
contractAddress: '0xYourBaseContract',
},
137: {
rpcUrl: 'https://polygon-mainnet.example.com',
contractAddress: '0xYourPolygonContract',
},
},
});
// Resolve config for a specific chain
const baseConfig = client.contracts.getChainConfig(8453);
// { rpcUrl: 'https://base-mainnet.example.com', contractAddress: '0xYourBaseContract' }
// Calling without an argument uses the client's default chainId
const defaultConfig = client.contracts.getChainConfig();When a chains map is provided, requesting an unlisted chain ID throws a GuildPassError with code INVALID_CONFIG:
// throws: No configuration found for chain ID 42161
client.contracts.getChainConfig(42161);The existing single-chain config (rpcUrl + contractAddress at the top level) remains fully backwards-compatible and is used as a fallback when no chains map is set.
Note
Known limitations: On-chain validation for WHITELIST access requirements is currently not supported and will throw a NOT_IMPLEMENTED error. For whitelist-style gating, we recommend using the SIWE-based or off-chain client.access.checkAccess() API instead.
Use evaluateRule() when you need to combine multiple independent access conditions on the client — for example, grant access if the backend check passes or the wallet holds enough membership tokens on-chain.
Each primitive delegates to the existing SDK service methods (client.access.checkAccess, client.contracts.getMembershipTokenBalance, client.roles.hasRole), so caching, retry, and error handling behave exactly as they do for direct calls. and / or nodes short-circuit: once the outcome is determined, remaining branches are not evaluated.
import { GuildPassClient, evaluateRule } from '@guildpass/sdk';
import { InMemoryCacheAdapter } from '@guildpass/sdk';
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
rpcUrl: 'https://mainnet.base.org',
contractAddress: '0x...',
cache: new InMemoryCacheAdapter(),
cacheTtl: 60_000,
});
const { granted } = await evaluateRule(
client,
{
type: 'or',
rules: [
{ type: 'accessCheck', resourceId: 'premium-docs' },
{ type: 'tokenBalanceAtLeast', minAmount: '1000000000000000000' },
],
},
{
walletAddress: '0x1234...5678',
guildId: 'prime-guild',
},
);
if (granted) {
// hybrid gate passed
}| Type | Delegates to | Notes |
|---|---|---|
accessCheck |
client.access.checkAccess |
Requires resourceId; inherits walletAddress / guildId from context |
tokenBalanceAtLeast |
client.contracts.getMembershipTokenBalance |
minAmount is raw base units (decimal string) |
hasRole |
client.roles.hasRole |
Requires roleId; inherits walletAddress / guildId from context |
and |
recursive | All sub-rules must pass; short-circuits on first denial |
or |
recursive | Any sub-rule may grant; short-circuits on first grant |
Nested trees are supported:
await evaluateRule(client, {
type: 'or',
rules: [
{
type: 'and',
rules: [
{ type: 'accessCheck', resourceId: 'staff-tools' },
{ type: 'hasRole', roleId: 'moderator' },
],
},
{ type: 'tokenBalanceAtLeast', minAmount: '5000000000000000000' },
],
}, context);Pass requestOptions on the evaluation context to forward timeouts, retry policy, or cancellation signals to every primitive in the tree:
await evaluateRule(client, rule, {
walletAddress,
guildId,
requestOptions: { timeoutMs: 1500, signal: controller.signal },
});client.contracts.getGuildOwner queries the resolved chain contract through JSON-RPC:
const ownerAddress = await client.contracts.getGuildOwner({
guildId: 'guild_1',
});You can override the target chain or contract per call:
const ownerAddress = await client.contracts.getGuildOwner({
guildId: '42',
chainId: 8453,
contractAddress: '0x1111111111111111111111111111111111111111',
});The SDK validates the RPC and contract configuration before making the call,
encodes the guild ID as bytes32, calls getGuildOwner(bytes32), and validates
that the RPC response decodes to an Ethereum address.
Contract reads inherit the SDK's transport configuration. This means they
support the same custom fetch transport, global timeoutMs, and retry
policy as standard API calls.
You can also provide per-call overrides for contract methods:
const owner = await client.contracts.getGuildOwner({
guildId: 'guild_1'
}, {
timeoutMs: 2000,
retry: { maxRetries: 2 }
});The batch helpers (getMembershipTokenBalancesBatch, getGuildOwnersBatch,
batchEthCall) combine multiple contract reads into a single JSON-RPC batch
request. By default, batches are limited to 100 calls to stay within
common RPC provider payload limits.
If a batch exceeds the limit, the SDK throws a GuildPassError with code
INVALID_INPUT:
// Throws: Batch size 200 exceeds maxBatchSize 100. Use chunk: true to split requests.
await client.contracts.getMembershipTokenBalancesBatch({
walletAddresses: twoHundredAddresses,
});Set chunk: true to automatically split oversized batches into sequential
requests of maxBatchSize each. Results are concatenated in order,
preserving the original input positions. Per-item errors remain isolated
across chunks.
const results = await client.contracts.getMembershipTokenBalancesBatch({
walletAddresses: twoHundredAddresses,
chunk: true, // split into 2 × 100 sequential batch calls
});
// results.length === 200, order matches walletAddressesOverride the default limit with maxBatchSize:
const results = await client.contracts.getGuildOwnersBatch({
guildIds: largeGuildList,
maxBatchSize: 50, // conservative limit for rate-limited provider
chunk: true,
});maxBatchSize must be a positive integer. Zero, negative and non-integer
values are rejected with an INVALID_INPUT error before any request is sent.
Omitting the option leaves the default of 100 in place.
The same options are available on the low-level batchEthCall method via
its options parameter:
await client.contracts.batchEthCall(calls, rpcUrl, {
maxBatchSize: 25,
chunk: true,
});When a cache adapter is configured, the SDK automatically deduplicates concurrent identical read requests. This ensures that if multiple callers request the same data at the same time, only one network request is issued.
import { GuildPassClient, InMemoryCacheAdapter } from '@guildpass/sdk';
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
cache: new InMemoryCacheAdapter(),
});
// Concurrent identical reads share the same in-flight promise.
const [g1, g2] = await Promise.all([
client.guilds.getGuild({ guildId: 'prime-guild' }),
client.guilds.getGuild({ guildId: 'prime-guild' }),
]); // Only 1 network request is made.The deduplication is scoped by the full cache key. If a request fails, the in-flight promise is removed so that subsequent calls can retry the network request.
The default timeout is 10 seconds. You can override this globally or for an individual service call:
const client = new GuildPassClient({
apiUrl: '...',
timeoutMs: 5000, // 5 seconds
});
const access = await client.access.checkAccess(params, {
timeoutMs: 1500, // override only this request
});
const guild = await client.guilds.getGuild(
{ guildId: 'prime-guild' },
{ timeoutMs: 2500 },
);The SDK treats caching as an optimization layer. Cache failures (e.g., a Redis connection timeout or a malformed entry) are non-fatal and will never prevent a successful API request.
- Graceful Fallback: If
cache.get()fails, the SDK will continue with a network request. - Safe Persistence: If
cache.set()fails, the SDK will still return the successful API response. - Isolated Invalidation: Failures during cache invalidation (
invalidateGuildCache,clearCache) are caught and do not bubble up to the caller.
Advanced users can observe cache failures by providing an onCacheError hook in the client configuration:
const client = new GuildPassClient({
apiUrl: '...',
cache: new RedisCacheAdapter(),
hooks: {
onCacheError: (payload) => {
console.error(`Cache ${payload.operation} failed for key: ${payload.key}`);
console.error(payload.error);
}
}
});The hook receives a CacheErrorHookPayload containing the operation name (get, set, delete, clear), the affected key (if any), and the original error.
For full details on implementing custom cache adapters — including TTL semantics, deleteByPrefix, serialisation, and production examples — see the Cache Adapters Guide.
The SDK ensures that sensitive information such as API keys and authorization headers are never passed to the cache layer. Cache keys only contain public identifiers like guild IDs, wallet addresses, and resource IDs.
Pass an AbortSignal via the signal option to cancel an in-flight request. The signal composes with the per-request timeout — whichever fires first wins.
const controller = new AbortController();
// Cancel after 2 seconds (e.g. component unmount, route change)
setTimeout(() => controller.abort(), 2000);
try {
// Standard API call
const data = await client.guilds.getGuild({ guildId }, {
signal: controller.signal,
});
// Contract read
const balance = await client.contracts.getMembershipTokenBalance({
walletAddress: '0x...',
}, { signal: controller.signal });
} catch (err) {
if (err instanceof GuildPassError && err.code === GuildPassErrorCode.REQUEST_CANCELLED) {
// Request was cancelled by the caller
} else if (err instanceof GuildPassError && err.code === GuildPassErrorCode.TIMEOUT) {
// Request exceeded the configured timeout
}
}Passing an already-aborted signal throws ABORTED immediately without making a network request.
By default the SDK makes a single attempt and throws on failure. You can enable automatic retries with exponential backoff via the retry option.
The SDK supports optional request lifecycle hooks so you can integrate calls with logging, metrics, tracing, and debugging tools.
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
retry: {
maxRetries: 3, // number of retries after the initial attempt
baseDelayMs: 200, // starting backoff delay, doubles each attempt
maxDelayMs: 5000, // backoff ceiling
retryableStatuses: [429, 500, 502, 503, 504], // default
},
});Pass retry in the request options to change the retry policy for one call.
Each field is merged independently: local values win, fields omitted locally
inherit the global retry configuration, and fields omitted from both use the
library defaults listed below.
For example, normal or background work can use the global policy while a
latency-sensitive access check limits retries. This call uses one retry from
the local override and inherits the global baseDelayMs of 300 ms:
import { GuildPassClient } from '@guildpass/sdk';
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
retry: {
maxRetries: 3,
baseDelayMs: 300,
},
});
const access = await client.access.checkAccess(
{
walletAddress: '0x1111111111111111111111111111111111111111',
guildId: 'prime-guild',
resourceId: 'members-area',
},
{
retry: { maxRetries: 1 },
},
);| Option | Default | Notes |
|---|---|---|
maxRetries |
0 |
Set to 0 to disable retries entirely. |
baseDelayMs |
200 |
Backoff starts here and doubles each attempt. |
maxDelayMs |
5000 |
Backoff will never exceed this value. |
retryableStatuses |
[429, 500, 502, 503, 504] |
4xx errors other than 429 are not retried. |
allowMutatingRetry |
false |
POST/PUT/PATCH/DELETE are not retried unless this is true. |
jitter |
true |
Randomizes each backoff by ±25% so many clients do not retry in lockstep. Set to false for deterministic delays (e.g. in tests). |
The SDK respects the Retry-After response header on retryable responses, waiting the server-specified duration before retrying rather than using the computed backoff. Every retry waits at least the computed backoff (or Retry-After, when present), whether or not a rateLimit bucket is configured.
Non-idempotent methods (POST, PATCH) are never retried unless you explicitly set allowMutatingRetry: true. Only enable this when you are certain the operation is safe to repeat.
hooks: {
onRequest: ({ method, path }) => {
console.log('request started', method, path);
},
onResponse: ({ method, path, status, durationMs }) => {
console.log('request succeeded', method, path, status, durationMs);
},
onError: ({ method, path, error, durationMs }) => {
console.error('request failed', method, path, error.message, durationMs);
},
},
});
Hook payloads expose safe request metadata only. Sensitive values like the API key, `Authorization` and `Cookie` headers, and full request body are not included in hook payloads. Headers are redacted consistently before reaching your callbacks, and hook failures are logged without changing the normal SDK response behavior.
⚠️ **Warning:** Be careful not to log sensitive application data. Although the SDK automatically redacts known sensitive headers (`authorization`, `x-api-key`, `cookie`, `set-cookie`), any proprietary query parameters or custom headers containing sensitive info should be handled securely.
## Response Metadata
Pass `includeMeta: true` in any service call's `RequestOptions` to receive diagnostic metadata alongside the response data. This is useful for correlating client-side failures with backend logs and support tickets without changing the default ergonomic API.
```typescript
import { GuildPassClient } from '@guildpass/sdk';
const client = new GuildPassClient({ apiUrl: 'https://api.guildpass.xyz' });
// Default: returns plain data (backwards-compatible)
const guild = await client.guilds.getGuild({ guildId: 'prime-guild' });
// → { id: 'prime-guild', name: 'Prime Guild', ... }
// Opt-in: includeMeta returns { data, meta }
const result = await client.guilds.getGuild(
{ guildId: 'prime-guild' },
{ includeMeta: true },
);
console.log(result.data.name); // 'Prime Guild'
console.log(result.meta.requestId); // 'req-abc-123' (if present)
console.log(result.meta.correlationId); // 'corr-xyz-789' (if present)
console.log(result.meta.traceId); // W3C traceparent (if present)
console.log(result.meta.status); // 200
console.log(result.meta.durationMs); // 142
| Field | Type | Description |
|---|---|---|
requestId |
string | undefined |
Value of the X-Request-ID response header. |
correlationId |
string | undefined |
Value of the X-Correlation-ID response header. |
traceId |
string | undefined |
Value of the Traceparent (W3C) response header. |
status |
number |
HTTP status code of the response. |
durationMs |
number |
Round-trip duration in milliseconds. |
All read-oriented service methods support includeMeta:
client.access.checkAccess(params, { includeMeta: true })client.access.checkRoleAccess(params, { includeMeta: true })client.membership.getMembership(params, { includeMeta: true })client.roles.getRoles(params, { includeMeta: true })client.roles.getUserRoles(params, { includeMeta: true })client.guilds.getGuild(params, { includeMeta: true })client.guilds.getGuildConfig(params, { includeMeta: true })
When an HTTP error occurs (4xx, 5xx), the thrown GuildPassError includes a requestMeta property with the same diagnostic information. This lets you correlate failures with backend logs even in catch blocks:
try {
await client.guilds.getGuild({ guildId: 'unknown' });
} catch (error) {
if (error instanceof GuildPassError) {
console.error(`Request failed`, {
code: error.code,
status: error.status,
requestId: error.requestMeta?.requestId,
correlationId: error.requestMeta?.correlationId,
});
}
}The requestMeta property is undefined for network errors, timeouts, and cancellations where no HTTP response was received.
Only the safe diagnostic headers (X-Request-ID, X-Correlation-ID, Traceparent) are captured. Sensitive headers like Authorization, X-API-Key, Cookie, and Set-Cookie are never exposed in response metadata. The metadata object is intentionally limited to fields useful for diagnostics and support.
verifySiweSignature checks a Sign-In With Ethereum (EIP-4361) message's
signature, domain, nonce, and expiry, but it does not track which nonces have
already been used. On its own, the exact same valid signed message can be
re-submitted and re-verified any number of times. The nonce exists to stop
this, but only if the relying party remembers which nonces it has already
accepted.
The SDK provides a pluggable NonceStore (mirroring the CacheAdapter
pattern), an in-memory reference implementation, and a
verifySiweSignatureWithReplayProtection wrapper that verifies the signature
and atomically consumes the nonce, rejecting any message whose nonce has
already been used.
import {
InMemoryNonceStore,
verifySiweSignatureWithReplayProtection,
} from '@guildpass/sdk';
const nonceStore = new InMemoryNonceStore();
const result = await verifySiweSignatureWithReplayProtection(
{ message: rawSiweMessage, signature },
nonceStore,
);
if (result.success) {
// First time through: verified and the nonce is now consumed.
} else {
// A second submission of the same message lands here with
// result.code === 'SIWE_REPLAY_DETECTED'.
}Verification runs first; the nonce is consumed only after the signature and all
EIP-4361 checks pass. A failed or malformed request therefore never burns a
nonce, so an attacker cannot grief a legitimate user by pre-consuming it. The
consumed marker's TTL is aligned with the message's expirationTime, so a nonce
is never pruned while the message it protects is still valid.
InMemoryNonceStore keeps its record in a single process. On a multi-instance
server (multiple nodes behind a load balancer, or serverless functions), a nonce
consumed on one instance is unknown to the others, which leaves a replay window
across the fleet. For those deployments, back the same NonceStore interface
with a shared store such as Redis.
The interface is deliberately small so this is straightforward:
import type { NonceStore } from '@guildpass/sdk';
import type { Redis } from 'ioredis';
class RedisNonceStore implements NonceStore {
constructor(private readonly redis: Redis) {}
async consume(nonce: string, ttl?: number): Promise<boolean> {
// SET key value NX PX <ttl> returns null when the key already exists.
// NX makes the check-and-consume atomic in a single round-trip, so two
// concurrent verifications of the same nonce cannot both succeed.
const key = `siwe:nonce:${nonce}`;
const outcome =
ttl && ttl > 0
? await this.redis.set(key, '1', 'PX', ttl, 'NX')
: await this.redis.set(key, '1', 'NX');
return outcome === 'OK';
}
async has(nonce: string): Promise<boolean> {
return (await this.redis.exists(`siwe:nonce:${nonce}`)) === 1;
}
}Redis SET ... NX gives the same atomic check-and-consume guarantee as the
in-memory store, and PX applies the TTL so consumed nonces expire on their own
without unbounded growth.
consume is the single authoritative replay gate: it returns true only when a
nonce was previously unused. Unlike the cache layer, a NonceStore failure is
not silently ignored. If the store throws while consuming, the wrapper fails
closed and rejects verification rather than risk accepting a replay it could not
rule out.