/** Server-side WingsFlow v1 client. Never embed credentials in browser bundles. */ export class WingsFlowError extends Error { constructor(status, body, requestId) { super(body?.error?.message || `WingsFlow HTTP ${status}`); this.status = status; this.code = body?.error?.code; this.requestId = requestId; this.retryable = body?.error?.retryable === true; } } export class WingsFlowClient { constructor({ baseUrl, apiKey, timeoutMs = 10000, fetch: transport = globalThis.fetch, }) { const u = new URL(baseUrl); if ( u.protocol !== 'https:' && !( u.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(u.hostname) ) ) throw Error('HTTPS API URL required'); if (u.username || u.password || u.search || u.hash) throw Error('Use a clean API base URL'); if (!/^wf_(test|live)_[0-9a-f-]+\.[A-Za-z0-9_-]+$/.test(apiKey || '')) throw Error('Invalid API credential'); this.baseUrl = baseUrl.replace(/\/$/, ''); this.apiKey = apiKey; this.timeoutMs = timeoutMs; this.transport = transport; } async request(method, path, { body, idempotencyKey, ifMatch } = {}) { if ( !path.startsWith('/') || path.startsWith('//') || path.includes('://') || path.includes('..') ) throw Error('Relative API path required'); if (method !== 'GET' && !idempotencyKey) throw Error('Persist an idempotency key before every mutation'); const response = await this.transport(this.baseUrl + path, { method, redirect: 'error', signal: AbortSignal.timeout(this.timeoutMs), headers: { Authorization: `Bearer ${this.apiKey}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), ...(ifMatch ? { 'If-Match': ifMatch } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), }); let result; try { result = await response.json(); } catch { throw new WingsFlowError( response.status, { error: { message: 'Non-JSON API response; retain your request identity and investigate', }, }, response.headers.get('x-request-id'), ); } if (!response.ok) { const e = new WingsFlowError( response.status, result, response.headers.get('x-request-id'), ); e.retryAfter = response.headers.get('retry-after'); throw e; } return { data: result.data, requestId: response.headers.get('x-request-id'), etag: response.headers.get('etag'), }; } me() { return this.request('GET', '/me'); } upsertContact(externalId, profile, key) { return this.request( 'PUT', '/contact-identities/' + encodeURIComponent(externalId), { body: profile, idempotencyKey: key }, ); } recordConsent(contactId, evidence, key) { return this.request( 'POST', `/contacts/${encodeURIComponent(contactId)}/consents`, { body: evidence, idempotencyKey: key }, ); } submitEvent(event, key) { return this.request('POST', '/events', { body: event, idempotencyKey: key, }); } sendMessage(message, key) { return this.request('POST', '/messages', { body: message, idempotencyKey: key, }); } event(id) { return this.request('GET', '/events/' + encodeURIComponent(id)); } message(id) { return this.request('GET', '/messages/' + encodeURIComponent(id)); } } // Retries belong in a durable outbox. Preserve the exact body, source_event_id and Idempotency-Key. // HTTP 202 means admitted, not delivered. Never blindly resend an unknown provider outcome.