# @fedpulse/sdk

[![npm version](https://img.shields.io/npm/v/@fedpulse/sdk)](https://www.npmjs.com/package/@fedpulse/sdk)
[![Node.js ≥18](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

Official JavaScript / TypeScript SDK for the [FedPulse API](https://app.fedpulse.dev). Search federal contract opportunities, check vendor exclusions, retrieve entity registrations, and access market intelligence — all with zero runtime dependencies.

---

## Features

- **Typed end-to-end** — full TypeScript definitions for every request and response
- **Dual ESM + CJS** — works in Node.js (ESM / CommonJS), Deno, Bun, and bundlers
- **Zero runtime dependencies** — only uses Node.js built-ins (`fetch`, `crypto`)
- **Automatic retry** — exponential back-off with full jitter for 5xx / network errors
- **In-memory LRU cache** — 256-entry, 60 s TTL on all GET requests
- **Webhook verification** — HMAC-SHA256 signature + replay-attack protection
- **Cursor + offset pagination** — async generator helpers for all list endpoints

---

## Requirements

- Node.js **≥ 18.0.0** (native `fetch` + `crypto`)
- A FedPulse API key — generate one at [app.fedpulse.dev/dashboard](https://app.fedpulse.dev/dashboard)

---

## Installation

```bash
npm install @fedpulse/sdk
```

---

## Quick start

```ts
import { FedPulse } from '@fedpulse/sdk';

const client = new FedPulse({ apiKey: process.env.FEDPULSE_API_KEY! });

// Search opportunities
const result = await client.opportunities.list({
  q: 'cybersecurity',
  naics: ['541519', '541512'],
  postedAfter: '2025-01-01',
  limit: 25,
});

console.log(result.data);        // typed OpportunitySummary[]
console.log(result.pagination);  // { total, page, limit, … }
```

---

## Authentication

Pass your API key to the constructor. It is sent as `X-Api-Key: <key>` on every request.

```ts
const client = new FedPulse({ apiKey: 'fp_live_…' });
```

Never hard-code keys in source — use environment variables.

---

## Resources

### `client.opportunities`

```ts
// List / search opportunities
await client.opportunities.list({ q: 'cloud', naics: ['541512'] });

// Retrieve a single opportunity
await client.opportunities.get('abc123-notice-id');

// Aggregate statistics
await client.opportunities.stats({ postedAfter: '2025-01-01' });

// Exports are background jobs — createExport() returns immediately with a job ID.
// Poll getExport() until status === 'done', then call downloadExport().
const job = await client.opportunities.createExport({ format: 'csv', q: 'AI' });
let status = job.data;
while (status.status !== 'done' && status.status !== 'failed') {
  await new Promise(r => setTimeout(r, 3000)); // wait 3s between polls
  status = (await client.opportunities.getExport(status.exportId)).data;
}
if (status.status === 'done') {
  const response = await client.opportunities.downloadExport(status.exportId);
  const csv = await response.text(); // or response.body for streaming
}

// Async generator — walks all pages automatically
for await (const page of client.opportunities.paginate({ q: 'defense' })) {
  console.log(page.data);
}
```

### `client.exclusions`

```ts
// List all current exclusions
await client.exclusions.list({ activeOnly: true });

// Retrieve one record
await client.exclusions.get('exclusion-id');

// Bulk check up to 100 entities
await client.exclusions.check({
  entities: [
    { uei: 'ABC123DEF456' },
    { cage: '1A2B3' },
    { name: 'Acme Corp' },
  ],
});

// Stats
await client.exclusions.stats();

// Page through all exclusions
for await (const page of client.exclusions.paginate()) {
  console.log(page.data);
}
```

### `client.entities`

```ts
// List registered entities
await client.entities.list({ naics: ['336411'], state: 'VA' });

// Entity by UEI
await client.entities.get('ABCDEF123456');

// Contracts awarded to an entity
await client.entities.opportunities('ABCDEF123456', { limit: 50 });

// Exclusion check (never cached)
await client.entities.exclusionCheck('ABCDEF123456');

// Stats
await client.entities.stats();

// All pages
for await (const page of client.entities.paginate({ state: 'TX' })) {
  console.log(page.data);
}
```

### `client.intelligence`

```ts
// 360° entity intelligence profile
await client.intelligence.entityProfile('ABCDEF123456');

// Market analysis for a NAICS code
await client.intelligence.marketAnalysis('541512');

// Single-entity compliance check
await client.intelligence.complianceCheck({ uei: 'ABCDEF123456' });
```

### `client.assistance`

Federal grants and assistance listings (CFDA programs).

```ts
// Search listings
await client.assistance.list({ q: 'broadband', agency: 'USDA' });

// Retrieve one by CFDA program number (format: "XX.XXX")
await client.assistance.get('10.001');

// Aggregated stats
await client.assistance.stats();

// Auto-paginate all listings
for await (const page of client.assistance.paginate({ agency: 'HHS' })) {
  console.log(page.data);
}
```

### `client.analytics`

Per-user API usage analytics — scoped to the authenticated key.

```ts
// KPI totals (requests today, this month, error rates)
await client.analytics.summary({ range: '30d' });

// Per-day breakdown for charting (up to 90 days)
await client.analytics.chart({ range: '30d' });

// Top endpoints by request volume and P95 latency
await client.analytics.endpoints({ range: '7d' });

// Paginated raw request log
await client.analytics.logs({ limit: 20 });
```

### `client.webhooks`

```ts
// List configured webhooks
await client.webhooks.list();

// Get a single webhook
await client.webhooks.get('00000000-0000-0000-0000-000000000001');

// Create
await client.webhooks.create({ url: 'https://example.com/hook', events: ['opportunity.new'] });

// Update / delete
await client.webhooks.update('00000000-0000-0000-0000-000000000001', { is_active: false });
await client.webhooks.delete('00000000-0000-0000-0000-000000000001');

// Send a test event to the endpoint immediately
await client.webhooks.test('00000000-0000-0000-0000-000000000001');

// Resume a paused webhook (auto-paused after 5 consecutive failures)
await client.webhooks.resume('00000000-0000-0000-0000-000000000001');

// Deliveries log
await client.webhooks.listDeliveries('00000000-0000-0000-0000-000000000001', { status: 'failed' });
await client.webhooks.getDelivery('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002');

// Auto-paginate all delivery history
for await (const page of client.webhooks.deliveryPages('00000000-0000-0000-0000-000000000001', { status: 'failed' })) {
  console.log(page.data);
}
```

---

## Webhook signature verification

```ts
import { FedPulse } from '@fedpulse/sdk';

// In your Express / Fastify handler:
app.post('/webhooks', (req, res) => {
  const { signatureHeader, timestampHeader } = FedPulse.extractWebhookHeaders(req.headers);

  let event;
  try {
    event = FedPulse.verifyWebhook({
      rawBody: req.body,           // Buffer or string — the raw unparsed body
      signatureHeader,
      timestampHeader,
      secret: process.env.FEDPULSE_WEBHOOK_SECRET!,
      options: { maxAgeSeconds: 300 },
    });
  } catch (err) {
    return res.status(400).send('Invalid signature');
  }

  console.log(event.event, event.data);
  res.sendStatus(200);
});
```

---

## Pagination helpers

Every list resource exposes a `paginate()` async generator that walks all pages for you:

```ts
const allOpps: OpportunitySummary[] = [];

for await (const page of client.opportunities.paginate({ q: 'cloud' })) {
  allOpps.push(...page.data);
}
```

---

## Error handling

All errors extend `FedPulseError`. Import specific classes for precise handling:

```ts
import {
  FedPulse,
  AuthenticationError,
  PermissionError,
  NotFoundError,
  ValidationError,
  RateLimitError,
  ServerError,
  NetworkError,
  TimeoutError,
  RetryExhaustedError,
} from '@fedpulse/sdk';

try {
  await client.opportunities.get('bad-id');
} catch (err) {
  if (err instanceof NotFoundError) {
    console.log('Not found');
  } else if (err instanceof RateLimitError) {
    console.log(`Rate limited. Retry after: ${err.retryAfter}s`);
  } else if (err instanceof RetryExhaustedError) {
    console.log(`Failed after ${err.attempts} attempts`, err.lastError);
  } else if (err instanceof NetworkError) {
    console.log('Network issue — check internet connection');
  }
}
```

| Class | Status | When thrown |
|---|---|---|
| `AuthenticationError` | 401 | Invalid or missing API key |
| `PermissionError` | 403 | Key lacks required permission |
| `NotFoundError` | 404 | Resource does not exist |
| `ValidationError` | 400/422 | Bad request parameters |
| `RateLimitError` | 429 | Too many requests (check `retryAfter`) |
| `ServerError` | 5xx | FedPulse server error |
| `NetworkError` | — | DNS failure, ECONNREFUSED, etc. |
| `TimeoutError` | — | Request exceeded `timeoutMs` |
| `RetryExhaustedError` | — | All retry attempts failed |

---

## Configuration

```ts
const client = new FedPulse({
  apiKey: 'fp_live_…',        // required
  baseUrl: 'https://api.fedpulse.dev', // optional override
  timeoutMs: 30_000,          // per-attempt timeout (default: 30 s)
  maxRetries: 3,              // retries for 5xx / network errors (default: 3)
  cacheSize: 256,             // LRU cache entries, 0 to disable (default: 256)
  cacheTtlMs: 60_000,         // cache TTL in ms (default: 60 s)
});
```

### Rate limit info

```ts
const { data } = await client.opportunities.list({ q: 'cloud' });
console.log(client.rateLimit);
// { limit: 1000, remaining: 987, reset: 1740000000 }
```

### Clear cache

```ts
client.clearCache();
```

---

## CommonJS usage

```js
const { FedPulse } = require('@fedpulse/sdk');
const client = new FedPulse({ apiKey: process.env.FEDPULSE_API_KEY });
```

---

## Community

- 💬 **[Discord](https://discord.gg/6C74wXN5SE)** — Ask questions, share what you build, get help in `#help`
- 🔧 [Dashboard](https://app.fedpulse.dev/dashboard) — Manage API keys, view logs and usage
- 📖 [Docs](https://app.fedpulse.dev/docs) — Full API reference
- 🐛 [Bug reports](https://github.com/fedpulse-API/api-issues) — GitHub Issues for the API

---

## License

MIT © [FedPulse](https://app.fedpulse.dev)
