Package Exports
- @daftarhq/sdk
Readme
Daftar Billing SDK
The official Node.js/TypeScript SDK for integrating your SaaS application with the Daftar billing platform.
Installation
Install from npm:
npm install @daftarhq/sdkQuick Start
import { DaftarClient } from "@daftarhq/sdk";
// Initialize the client with your API key
const daftar = new DaftarClient({
apiKey: "sk_your_api_key_here",
baseUrl: "https://daftarhq.com/api",
});
// Create a customer with external ID (your system's user ID)
const customer = await daftar.customers.create({
externalId: "user_123", // Your system's user ID
name: "Acme Corporation",
});
// Your app chooses the market key. Daftar does not infer geography.
const marketKey = "saudi-arabia";
// Fetch the checkout-ready catalog for this exact plan and market.
const catalog = await daftar.pricing.getPlanCatalog("pro", {
marketKey,
billingPeriod: "monthly",
});
if (!catalog.marketPrice) throw new Error("Monthly price is not configured");
// Resolve the customer's wallet in the selected market currency.
const wallet = await daftar.wallets.getOrCreate(customer.id, catalog.marketPrice.currency);
// Top up the wallet
await wallet.topUp({
amount: 1000, // decimal amount in catalog.marketPrice.currency
reference: "Initial deposit",
idempotencyKey: "deposit_user_123_initial",
});
// Grant admin credit without treating it as a customer payment.
await wallet.grantCredit({
amount: 25,
reference: "Goodwill credit",
idempotencyKey: "manual_credit_user_123_001",
});
// Subscribe to a plan
const subscription = await daftar.subscriptions.create({
customerExternalId: "user_123",
planId: "pro",
marketKey,
billingPeriod: "monthly",
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
contract: {
basePrice: 700,
notes: "Enterprise launch contract",
meterOverrides: [
{
eventId: "api_calls",
pricingModel: "per_unit",
unitPrice: 0.01,
includedUnits: 50000,
chargeOverage: true,
limitType: "event_quota",
eventQuotaWindow: "billing_period",
},
],
},
});
// Track usage with idempotency
await daftar.usage.create({
customerExternalId: "user_123",
eventId: "api_calls",
value: 1000,
idempotencyKey: "usage_user_123_2024_01_15_batch_1",
});Stable Keys vs UUIDs
Daftar stores UUIDs internally and still returns them for audit/debugging, but project-scoped SDK calls should use stable keys:
customerExternalId: your app's user/account ID.planId: the stable plan key, such asfree,pro, orenterprise.marketKey: the market selected by your app, such asglobalorsaudi-arabia.eventId: the stable meter key, such asapi_calls.
const catalog = await daftar.pricing.getPlanCatalog("pro", {
marketKey: "global",
billingPeriod: "monthly",
});
await daftar.subscriptions.create({
customerExternalId: "user_123",
planId: "pro",
marketKey: "global",
billingPeriod: "monthly",
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
await daftar.usage.create({
customerExternalId: "user_123",
eventId: "api_calls",
value: 1000,
});Authentication
The SDK uses API key authentication (Bearer token). You can create API keys through the Daftar dashboard:
// The SDK automatically handles the Authorization header
const client = new DaftarClient({
apiKey: "sk_live_abcdef1234567890abcdef1234567890",
});Project Scoping (projectId)
Daftar API keys are scoped to a single project.
- For project-scoped SDK calls, the backend can infer
projectIdfrom the API key, soprojectIdis optional in the request. - If you do provide
projectId, it must match the API key's project, otherwise the backend returns403.
Project Configuration
Project creation, balance/market creation, jobs, dashboard, and API key management are session-only (cookie auth) for the admin dashboard. This SDK is intentionally API-key only, so those endpoints are not available via the SDK.
Read-only market and balance discovery is available through the SDK:
const { data: balances } = await daftar.balances.list(projectId);
const { data: markets } = await daftar.markets.list(projectId);
const market = markets.find((item) => item.name === "GCC" && item.currency === "USD");Plan Prices and Meter Pricing
Usage pricing is scoped to the exact plan price customers subscribe to. Before showing checkout, fetch the catalog for the market your app selected:
const { data: catalogs, unavailable } = await daftar.pricing.listPlanCatalogs({
marketKey: "global",
billingPeriod: "monthly",
limit: 20,
});
for (const catalog of catalogs) {
if (!catalog.marketPrice) continue;
console.log(catalog.plan.name, catalog.marketPrice.currency, catalog.marketPrice.basePrice);
}
if (unavailable.length > 0) {
console.warn(
"Some plans are not configured for this market:",
unavailable.map((item) => item.plan.planId),
);
}For a single selected plan:
const catalog = await daftar.pricing.getPlanCatalog(plan.id, {
marketKey: "global",
billingPeriod: "monthly",
});
if (!catalog.marketPrice) throw new Error("Select a billing period");
console.log(catalog.marketPrice.currency, catalog.marketPrice.basePrice);
for (const meterPrice of catalog.meterPrices) {
console.log(meterPrice.meterEventId, meterPrice.includedUnits);
}When configuring a plan, create or resolve the plan price first, then attach meter pricing with planPriceId:
const planPrice = await daftar.pricing.addPriceToPlan(plan.id, {
marketKey: "global",
basePrice: 15,
billingPeriod: "monthly",
});
const ksaPrice = await daftar.pricing.addPriceToPlan(plan.id, {
marketKey: "ksa",
basePrice: 55,
billingPeriod: "monthly",
copyMeterPricingFromPlanPriceId: planPrice.id,
});
await daftar.pricing.addToPlan(plan.id, {
marketKey: "global",
billingPeriod: "monthly",
eventId: "api_calls",
pricingModel: "per_unit",
unitPrice: 0.02,
includedUnits: 10000,
chargeOverage: true,
limitType: "event_quota",
eventQuotaWindow: "billing_period",
});Core Features
🔗 Fluent API
The SDK provides a fluent API where resource objects have methods to perform related operations:
// Create a customer
const customer = await daftar.customers.create({
externalId: "user_123",
name: "Acme Corp",
});
// Subscribe using fluent API
const subscription = await customer.subscribe({
planId: "pro",
marketKey: "global",
billingPeriod: "monthly",
contract: {
basePrice: 700,
notes: "Negotiated terms at subscription creation",
meterOverrides: [
{
eventId: "api_calls",
pricingModel: "per_unit",
unitPrice: 0.01,
includedUnits: 50000,
chargeOverage: true,
limitType: "event_quota",
eventQuotaWindow: "billing_period",
},
],
},
});
// Track usage using fluent API
const usage = await customer.recordUsage({
eventId: "api_calls",
value: 1000,
});
// Get related data
const invoices = await customer.getInvoices();
const subscriptions = await customer.getSubscriptions();
// Resource methods on subscription
await subscription.cancel({ cancelAtPeriodEnd: true });
const planChange = await subscription.changePlan({
newPlanId: "enterprise",
billingPeriod: "monthly",
timing: "immediate",
});
// Immediate same-period upgrades first charge any positive net adjustment from
// the customer's wallet. A low wallet balance returns code=insufficient_wallet_balance.
if (planChange.scheduledChange?.reason === "billing_period_change") {
console.log("Plan changes at renewal:", planChange.scheduledChange.effectiveAt);
}
// Resource methods on wallet
const wallet = await daftar.wallets.getOrCreate(customer.id, "SAR");
await wallet.topUp({ amount: 100, idempotencyKey: "topup_user_123_001" });
await wallet.grantCredit({
amount: 25,
reference: "Goodwill credit",
idempotencyKey: "manual_credit_user_123_001",
});
const transactions = await wallet.getTransactions();🎯 Usage Tracking
Track customer usage for billing and analytics:
// Single usage record
await client.usage.create({
customerExternalId: "user_123",
eventId: "api_calls",
value: 1000,
timestamp: "2024-01-15T10:00:00Z",
});
// Batch usage records
await client.usage.createBatch([
{
customerExternalId: "user_123",
eventId: "api_calls",
value: 500,
},
{
customerExternalId: "user_123",
eventId: "seats",
value: 10,
},
]);
// Get usage history
const usage = await client.usage.getCustomerUsage("00000000-0000-0000-0000-000000000000", {
startDate: "2024-01-01",
endDate: "2024-01-31",
limit: 100,
});
// Get aggregated usage statistics
const stats = await client.usage.getAggregatedUsage("00000000-0000-0000-0000-000000000000", {
startDate: "2024-01-01",
endDate: "2024-01-31",
});🚦 Access Gating
Check whether a customer can use a feature or metered action:
const decision = await client.access.check({
customerExternalId: "user_123",
feature: "api_calls",
quantity: 1,
});
if (!decision.allowed) {
console.log(`Blocked by billing: ${decision.reason}`);
}The same check is available from a customer resource:
const decision = await customer.can("api_calls", { quantity: 1 });Build support dashboards or customer-facing usage pages from the pinned plan-version state:
const state = await client.access.getCustomerState("user_123");
for (const entitlement of state.entitlements) {
console.log(`${entitlement.feature}: ${entitlement.used}/${entitlement.limit ?? "metered"}`);
}For high-volume request paths, store access state locally and update it from Daftar webhooks such as usage.limit_reached, usage.limit_reset, subscription.past_due, subscription.recovered, and customer.access_changed.
Webhooks
Daftar sends webhooks as a stable envelope with an event-specific data object:
{
"id": "evt_123",
"event": "usage.limit_reached",
"createdAt": "2026-05-15T12:00:00.000Z",
"data": {
"customerId": "cus_123",
"subscriptionId": "sub_123",
"feature": "api_calls",
"meterId": "mtr_123",
"used": 5000,
"limit": 5000,
"remaining": 0,
"overageAllowed": false,
"limitType": "event_quota",
"eventQuotaWindow": "billing_period",
"resetAt": "2026-06-15T12:00:00.000Z"
}
}Verify the signature against the raw request body before parsing JSON:
import {
parseAndValidateWebhookPayload,
toWebhookUpdate,
verifyWebhookSignature,
type DaftarWebhookPayload,
type WebhookUpdate,
} from "@daftarhq/sdk";
// Express example: mount this route with express.raw({ type: "application/json" }).
app.post("/webhooks/daftar", express.raw({ type: "application/json" }), async (req, res) => {
const payload = req.body.toString("utf8");
const signature = req.header("X-Webhook-Signature");
if (
!verifyWebhookSignature({
payload,
signature,
secret: process.env.DAFTAR_WEBHOOK_SECRET!,
})
) {
return res.status(400).send("Invalid signature");
}
const event = parseAndValidateWebhookPayload(payload);
await handleDaftarWebhook(event);
res.sendStatus(204);
});
async function handleDaftarWebhook(event: DaftarWebhookPayload) {
const update = toWebhookUpdate(event);
await applyWebhookUpdate(update);
}
async function applyWebhookUpdate(update: WebhookUpdate) {
if (update.action === "block_access") {
await blockAccessLocally(update.customerId!, update.feature, update.reason);
}
if (update.action === "allow_access" || update.action === "refresh_entitlements") {
await refreshEntitlements(update.customerId!);
}
}The SDK exports WEBHOOK_EVENTS, per-event runtime schemas through webhookPayloadSchemas, strict helpers such as validateWebhookPayload / parseAndValidateWebhookPayload, and payload types such as UsageLimitReachedWebhookData. Runtime schemas require documented fields and allow additive fields for forward compatibility. The public developer docs at /docs#webhook-payloads include the full payload reference and examples for every event.
👥 Customer Management
Manage your customers:
// Create customer
const customer = await client.customers.create({
externalId: "user_123", // Your system's user ID
name: "John Doe",
email: "john@example.com",
});
// List customers
const customers = await client.customers.list({
limit: 50,
search: "John",
});
// Update customer
await client.customers.update("user_123", {
name: "John Smith",
});📋 Subscription Management
Handle customer subscriptions with full lifecycle support:
// Create subscription
const now = new Date();
const subscription = await daftar.subscriptions.create({
customerExternalId: "user_123",
planId: "pro",
marketKey: "global",
billingPeriod: "monthly",
currentPeriodStart: now,
currentPeriodEnd: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
status: "active",
contract: {
basePrice: 700,
notes: "Enterprise launch contract",
meterOverrides: [
{
eventId: "api_calls",
pricingModel: "per_unit",
unitPrice: 0.01,
includedUnits: 50000,
chargeOverage: true,
limitType: "event_quota",
eventQuotaWindow: "billing_period",
},
],
},
});
// Check subscription status
if (subscription.isActive()) {
console.log("Subscription is active");
}
if (subscription.isTrialing()) {
console.log(`Trial ends: ${subscription.trialEnd}`);
}
// Cancel at period end (no refund)
await subscription.cancel({
cancelAtPeriodEnd: true,
});
// Cancel immediately
await subscription.cancel({
cancelAtPeriodEnd: false,
});
// Upgrade/downgrade plan. Same-billing-period changes can apply immediately
// with proration after any positive net adjustment is paid from the customer's
// wallet; monthly/yearly switches are scheduled for renewal.
const { proration, scheduledChange } = await subscription.changePlan({
newPlanId: "enterprise",
billingPeriod: "monthly",
timing: "immediate",
});
if (proration) {
console.log(
`Net adjustment: ${proration.netAdjustment} (refund=${proration.prepaidRefund}, charge=${proration.newPlanCharge}, usage=${proration.usageCharges})`,
);
}
if (scheduledChange) {
console.log(`Plan changes at renewal: ${scheduledChange.effectiveAt}`);
}
// Inspect subscription-specific custom contract history, if the subscription
// was created with contract terms.
const contractHistory = await subscription.listContracts();
// Inspect the effective signed terms used by billing and access checks.
const terms = await subscription.getResolvedTerms();
console.log(terms.basePrice.amount, terms.basePrice.source);
// List customer subscriptions
const subs = await daftar.subscriptions.listForCustomer("00000000-0000-0000-0000-000000000000");💳 Wallet Operations
Manage customer wallets with idempotent operations:
const wallet = await daftar.wallets.getOrCreate("00000000-0000-0000-0000-000000000000", "SAR");
// Top up wallet with idempotency key (prevents duplicate charges)
const { transaction, _idempotent } = await wallet.topUp({
amount: 500, // decimal amount in this wallet currency
reference: "Payment via bank transfer",
idempotencyKey: "payment_inv_123",
});
if (_idempotent) {
console.log("This top-up was already processed");
}
// Grant admin credit separately from customer-paid deposits.
await wallet.grantCredit({
amount: 50,
reference: "SLA credit",
idempotencyKey: "manual_credit_sla_123",
});
// Check balance
console.log(`Current balance: ${wallet.balance}`);
// Refresh balance from server
await wallet.refreshBalance();
// Check if sufficient for a purchase
if (wallet.hasSufficientBalance(100)) {
console.log("Can afford the purchase");
}
// Get transaction history
const { data: transactions, total } = await wallet.getTransactions({
limit: 50,
});📄 Invoice Management
Generate and manage invoices:
// Create invoice
const invoice = await client.invoices.create({
customerId: "00000000-0000-0000-0000-000000000000",
periodStart: "2024-02-01",
periodEnd: "2024-02-29",
subtotal: 99,
total: 99,
dueDate: "2024-02-15",
status: "open",
type: "manual",
});
// Add line items
await client.invoices.addLineItem(invoice.id, {
description: "Pro Plan - Monthly",
quantity: 1,
unitPrice: 99,
amount: 99,
});
// Send invoice using resource method
await invoice.send();
// Pay invoice from customer's wallet
await invoice.pay();
// Get customer's invoices using fluent API
const customer = await client.customers.get("00000000-0000-0000-0000-000000000000");
const invoices = await customer.getInvoices({ limit: 10 });High-Throughput Usage Tracking
For multi-instance deployments (e.g., Kubernetes), use the UsageBuffer for efficient batching:
import { DaftarClient, UsageBuffer } from "@daftarhq/sdk";
const daftar = new DaftarClient({ apiKey: "sk_..." });
// Create a buffer that batches events
const usageBuffer = new UsageBuffer(daftar, {
maxBufferSize: 100, // Flush when 100 events buffered
flushIntervalMs: 5000, // Or every 5 seconds
maxRetries: 3, // Retry failed flushes
instanceId: process.env.POD_NAME, // K8s pod name for deduplication
onFlushError: (err, events) => {
console.error(`Failed to flush ${events.length} events:`, err);
},
});
// Track usage (non-blocking, buffers locally)
usageBuffer.track({
customerExternalId: "user_123",
eventId: "api_calls",
value: 1,
});
// Graceful shutdown (flushes remaining events)
process.on("SIGTERM", async () => {
await usageBuffer.shutdown();
process.exit(0);
});Redis-Backed Distributed Buffer
For cross-instance deduplication before hitting the API:
import { DaftarClient, RedisUsageBuffer } from "@daftarhq/sdk";
import Redis from "ioredis";
const redis = new Redis();
const daftar = new DaftarClient({ apiKey: "sk_..." });
const usageBuffer = new RedisUsageBuffer(daftar, {
redis: {
setnx: (k, v) => redis.setnx(k, v).then((r) => r === 1),
expire: (k, s) => redis.expire(k, s).then(() => {}),
lpush: (k, v) => redis.lpush(k, v).then(() => {}),
lrange: (k, s, e) => redis.lrange(k, s, e),
ltrim: (k, s, e) => redis.ltrim(k, s, e).then(() => {}),
},
keyPrefix: "myapp:billing:",
idempotencyTtlSeconds: 86400, // 24 hours
});
// Returns false if duplicate (already tracked by another instance)
const isNew = await usageBuffer.trackWithDedup({
customerExternalId: "user_123",
eventId: "api_calls",
value: 1,
});Integrity Guarantees
| Layer | Mechanism | Protection |
|---|---|---|
| SDK | Auto-generated idempotency keys | Instance-level deduplication |
| SDK + Redis | SETNX before buffering |
Cross-instance deduplication |
| Server | DB unique index on (customer_id, idempotency_key) |
Final guarantee |
Error Handling
The SDK provides comprehensive error handling:
import { DaftarClient, DaftarApiError } from "@daftarhq/sdk";
try {
await client.customers.get("invalid-id");
} catch (error) {
if (error instanceof DaftarApiError) {
console.error("API Error:", error.error.message);
console.error("Status Code:", error.statusCode);
console.error("Error Details:", error.error.errors);
} else {
console.error("Unexpected error:", error);
}
}Advanced Configuration
const client = new DaftarClient({
apiKey: "your-api-key",
baseUrl: "https://daftarhq.com/api",
timeout: 15000, // Request timeout in ms
retries: 3, // Number of retry attempts
});Retries cover network failures, 5xx responses, and 429 rate-limit responses. When the API returns Retry-After, the SDK waits for that duration before retrying.
TypeScript Support
The SDK is fully typed with TypeScript. All methods have proper type definitions:
import { Customer, CreateCustomerRequest } from "@daftarhq/sdk";
const customerData: CreateCustomerRequest = {
externalId: "user_123",
name: "Acme Corp",
};
const customer: Customer = await client.customers.create(customerData);Examples
Check out the examples directory for more comprehensive usage examples:
- Basic Usage - Core SDK functionality
- Usage Tracking - Advanced usage tracking
- Subscription Management - Complete subscription workflow
Development
# Install dependencies
npm install
# Build the SDK
npm run build
# Run tests
npm test
# Start development mode with watch
npm run devAPI Reference
For detailed API documentation, visit Daftar API Docs.
Support
- Documentation: https://daftarhq.com/docs
- Support: https://daftarhq.com
License
MIT © Daftar