Integration Guide
A practical path from zero to a working two-way integration: initial pull, ongoing sync via webhooks, the end-to-end sourcing flow, key rotation, and error handling.
1. Initial pull
Pull reference data first, then the entities that point at it. Use limit=100 and derive the end of the list from offset + data.length >= total.
GET /v1/countries,GET /v1/regions,GET /v1/categories,GET /v1/product-categories— cache these; they change rarely.GET /v1/businesses?limit=100&offset=…— page until exhausted.GET /v1/products?limit=100&offset=…— page until exhausted. Products already embed variants,product_pricing_tiers, and a business summary, so you do not need a per-product call.- Record the timestamp of the pull. Everything after this is webhook-driven.
Do the first pull at a low concurrency — one request at a time is enough for 7k businesses at 100 per page — so the per-minute limiter never becomes the bottleneck.
2. Stay in sync with webhooks
Create one subscription per event you care about, store each returned secret, and treat every delivery as "something changed, go re-read it".
const EVENTS = [
'business.created',
'business.updated',
'product.created',
'product.updated',
'rfq.status_changed',
'rfq.quote_submitted',
] as const;
for (const event_type of EVENTS) {
const sub = await directoryApi<{ data: { id: string; secret: string } }>('/v1/webhooks', {
method: 'POST',
body: JSON.stringify({
event_type,
target_url: 'https://sourcing.example.com/hooks/directory',
}),
});
// The secret is returned ONCE. Persist it now.
await saveWebhookSecret(event_type, sub.data.id, sub.data.secret);
}export async function handleDirectoryWebhook(body: {
event: string;
timestamp: string;
data: Record<string, unknown>;
}) {
// Acknowledge fast, work later — delivery is fire-and-forget and is not retried.
await enqueue(async () => {
switch (body.event) {
case 'rfq.quote_submitted': {
// Re-read: the webhook payload has a bare price, the API has amount + currency.
const quotes = await directoryApi(`/v1/rfq/${body.data['rfq_id']}/quotes`);
await upsertQuotes(quotes);
break;
}
case 'business.updated':
await refreshBusiness(String(body.data['business_id']));
break;
default:
break;
}
});
return { ok: true };
}Because deliveries are not retried, run a low-frequency reconciliation sweep (for example a nightly GET /v1/rfq?limit=100 over open RFQs) so a dropped delivery cannot leave you permanently stale.
3. Worked example — find suppliers, raise an RFQ, read quotes
// ── a) Find candidate suppliers ────────────────────────────────
const found = await directoryApi<{
query: string;
businesses: { data: Array<{ id: string; name: string }>; total: number };
}>('/v1/search?q=' + encodeURIComponent('office chair manufacturer') + '&type=business&limit=10');
const candidates = found.businesses.data.slice(0, 3);
// ── b) Raise the RFQ ───────────────────────────────────────────
const rfq = await directoryApi<{ data: { id: string; rfq_number: string } }>(
'/v1/rfq/inbound',
{
method: 'POST',
body: JSON.stringify({
external_rfq_id: 'PROC-2026-0042',
title: 'Office furniture — Q3',
buyer_name: 'Andi Pratama',
buyer_company: 'Nusantara Works',
buyer_email: 'andi@nusantara.example',
delivery_location: 'Denpasar, Bali',
delivery_date: '2026-10-15',
items: [{
name: 'Ergonomic office chair',
quantity: 40,
unit_of_measure: 'piece',
target_price: 1_500_000,
target_currency: 'IDR', // amount + currency, always
category_slug: 'furniture',
}],
}),
}
);
const rfqId = rfq.data.id;
// ── c) Attach the shortlisted suppliers ────────────────────────
for (const business of candidates) {
await directoryApi(`/v1/rfq/${rfqId}/suppliers`, {
method: 'POST',
body: JSON.stringify({ business_id: business.id }),
});
}
// ── d) Read quotes (or wait for rfq.quote_submitted) ───────────
const quotes = await directoryApi<{
rfq_number: string;
suppliers: Array<{
supplier_id: string;
business_name: string;
status: string;
quoted_price: number | null;
quoted_currency: string | null;
quoted_lead_time_days: number | null;
}>;
}>(`/v1/rfq/${rfqId}/quotes`);
for (const s of quotes.suppliers) {
if (s.status !== 'quoted' || s.quoted_price == null) continue;
// Never format a price without its currency.
console.log(s.business_name, formatMoney(s.quoted_price, s.quoted_currency ?? 'IDR'));
}4. Key rotation
- Ask the Directory admin to issue a second key with the same scopes. Both keys work at once — there is no single-active-key constraint.
- Deploy the new key to your environment (read it from a secret store, never from source).
- Watch the Directory API request log until traffic on the old key drops to zero.
- Ask the admin to deactivate the old key. Deactivation is immediate: the next request with it returns
401 Invalid API key. - Webhook subscriptions belong to the key that created them. Before retiring a key, recreate its subscriptions under the new key (you will get new secrets) and delete the old ones.
Keys can also carry an expires_at. An expired key fails with 401 even while it is still marked active — schedule rotation ahead of the expiry, not after the outage.
5. Error handling
async function withRetry<T>(fn: () => Promise<T>, attempts = 4): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const status = (err as { status?: number }).status;
// 4xx other than 429 are your bug — do not retry them.
if (status && status !== 429 && status < 500) throw err;
await new Promise((r) => setTimeout(r, 2 ** i * 500 + Math.random() * 250));
}
}
throw lastErr;
}- Retry
429and5xxwith exponential backoff and jitter; never retry400,401,403,404. 503from/v1/semantic-searchmeans embeddings are not configured — fall back to/v1/search, do not retry in a loop.- A
403names the scope it wanted (Forbidden: products.read required) — surface it, do not silently swallow it. - Log the full
errorstring; it is written for humans.