From b512c5b6b2fcb92b4e6302225951932489e60877 Mon Sep 17 00:00:00 2001 From: javorosas Date: Sat, 16 May 2026 17:10:26 +0200 Subject: [PATCH 1/3] feat: support custom request headers --- CHANGELOG.md | 1 + package-lock.json | 4 +-- package.json | 2 +- src/index.ts | 3 +- src/wrapper.ts | 2 ++ test/node/runtime-compat.node.test.ts | 24 +++++++++++++++ test/web/runtime-compat.web.test.ts | 44 ++++++++++++++++++++++++++- 7 files changed, 75 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccca5d3..068af7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [4.17.0] 2026-04-27 ### Added +- Add support for custom request headers through the `Facturapi` constructor options. - Add `receipts.toInvoice` to create customer invoices from multiple receipt keys. - Add `receipts.previewToInvoicePdf` to generate PDF previews for to-invoice payloads. diff --git a/package-lock.json b/package-lock.json index 758d048..97ef798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "facturapi", - "version": "4.16.0", + "version": "4.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "facturapi", - "version": "4.16.0", + "version": "4.17.0", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 35135ba..5c916e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "facturapi", - "version": "4.16.0", + "version": "4.17.0", "description": "SDK oficial de Facturapi para Node.js y navegadores. Integra facturación electrónica en México (CFDI) de forma simple y obtén una perspectiva fiscal completa de tu operación, con búsquedas indexadas, envío de documentos y trazabilidad.", "main": "dist/index.cjs.js", "module": "dist/index.es.js", diff --git a/src/index.ts b/src/index.ts index c57eef6..1250a9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export type ApiVersion = 'v1' | 'v2'; export interface FacturapiOptions { apiVersion?: ApiVersion; + headers?: Record; } /** @@ -193,7 +194,7 @@ export default class Facturapi { } else { this.apiVersion = DEFAULT_API_VERSION; } - this._wrapper = createWrapper(apiKey, this.apiVersion); + this._wrapper = createWrapper(apiKey, this.apiVersion, options.headers); this.customers = new Customers(this._wrapper); this.products = new Products(this._wrapper); this.invoices = new Invoices(this._wrapper); diff --git a/src/wrapper.ts b/src/wrapper.ts index 8a85b64..71008df 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -104,9 +104,11 @@ const responseInterceptor = async (response: Response) => { export const createWrapper = ( apiKey: string, apiVersion: 'v1' | 'v2' = DEFAULT_API_VERSION, + headers: Record = {}, ) => { let baseURL = apiVersion === 'v1' ? BASE_URL_V1 : BASE_URL; const defaultHeaders = { + ...headers, Authorization: `Bearer ${apiKey}`, }; diff --git a/test/node/runtime-compat.node.test.ts b/test/node/runtime-compat.node.test.ts index 2457360..8b6c517 100644 --- a/test/node/runtime-compat.node.test.ts +++ b/test/node/runtime-compat.node.test.ts @@ -49,6 +49,30 @@ describe('runtime compatibility (node)', () => { await client.invoices.retrieve('inv_123') }) + it('sends custom headers in Node', async () => { + const client = new Facturapi('sk_test_123', { + headers: { + 'x-facturapi-client': 'MCP', + Authorization: 'Bearer ignored', + }, + }) + client.BASE_URL = 'https://api.test.local/v2' + + globalThis.fetch = vi.fn(async (_url, options) => { + expect(getHeader(options?.headers, 'Authorization')).toBe( + 'Bearer sk_test_123', + ) + expect(getHeader(options?.headers, 'x-facturapi-client')).toBe('MCP') + + return new Response(JSON.stringify({ id: 'org_123' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) as typeof fetch + + await client.organizations.me() + }) + it('parses JSON responses and sends auth header', async () => { const client = createClient() diff --git a/test/web/runtime-compat.web.test.ts b/test/web/runtime-compat.web.test.ts index 3ce3c40..3ac66a9 100644 --- a/test/web/runtime-compat.web.test.ts +++ b/test/web/runtime-compat.web.test.ts @@ -62,6 +62,42 @@ describe('runtime compatibility (web simulation)', () => { await client.invoices.retrieve('inv_123') }) + it('sends custom headers in web-like runtime', async () => { + const client = new Facturapi('sk_test_123', { + headers: { + 'x-facturapi-client': 'MCP', + Authorization: 'Bearer ignored', + }, + }) + client.BASE_URL = 'https://api.test.local/v2' + + globalThis.fetch = vi.fn(async (_url, options) => { + expect(getHeader(options?.headers, 'Authorization')).toBe( + 'Bearer sk_test_123', + ) + expect(getHeader(options?.headers, 'x-facturapi-client')).toBe('MCP') + + return { + ok: true, + headers: { + get(name: string) { + return name.toLowerCase() === 'content-type' + ? 'application/json' + : null + }, + }, + async json() { + return { id: 'org_123' } + }, + async text() { + return '' + }, + } as unknown as Response + }) as typeof fetch + + await client.organizations.me() + }) + it('surfaces API message from non-OK JSON responses in web-sim', async () => { const client = createClient() @@ -222,7 +258,12 @@ describe('runtime compatibility (web simulation)', () => { }) it('sends multipart FormData without forcing content-type header', async () => { - const client = createClient() + const client = new Facturapi('sk_test_123', { + headers: { + 'x-facturapi-client': 'MCP', + }, + }) + client.BASE_URL = 'https://api.test.local/v2' globalThis.fetch = vi.fn(async (url, options) => { expect(url).toBe('https://api.test.local/v2/organizations/org_123/logo') @@ -232,6 +273,7 @@ describe('runtime compatibility (web simulation)', () => { expect(getHeader(options?.headers, 'Authorization')).toBe( 'Bearer sk_test_123', ) + expect(getHeader(options?.headers, 'x-facturapi-client')).toBe('MCP') return new Response( JSON.stringify({ From b695b9c8902266e52e1b5dcafbd17774297b02ab Mon Sep 17 00:00:00 2001 From: javorosas Date: Sat, 16 May 2026 19:49:52 +0200 Subject: [PATCH 2/3] fix: normalize default request headers --- src/wrapper.ts | 17 +++++++++-------- test/node/runtime-compat.node.test.ts | 6 +++++- test/web/runtime-compat.web.test.ts | 7 ++++++- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/wrapper.ts b/src/wrapper.ts index 71008df..34b8a84 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -107,10 +107,10 @@ export const createWrapper = ( headers: Record = {}, ) => { let baseURL = apiVersion === 'v1' ? BASE_URL_V1 : BASE_URL; - const defaultHeaders = { - ...headers, - Authorization: `Bearer ${apiKey}`, - }; + const defaultHeaders = new Headers(headers); + defaultHeaders.delete('Authorization'); + defaultHeaders.delete('Content-Type'); + defaultHeaders.set('Authorization', `Bearer ${apiKey}`); const client = { get baseURL(): string { @@ -132,12 +132,13 @@ export const createWrapper = ( const queryString = params ? '?' + new URLSearchParams(params).toString() : ''; + const requestHeaders = new Headers(defaultHeaders); + if (!formData) { + requestHeaders.set('Content-Type', 'application/json'); + } const fetchOptions: RequestInit = { ...restOptions, - headers: { - ...defaultHeaders, - ...(formData ? {} : { 'Content-Type': 'application/json' }), - }, + headers: requestHeaders, body: formData ? (formData as BodyInit) : body diff --git a/test/node/runtime-compat.node.test.ts b/test/node/runtime-compat.node.test.ts index 8b6c517..38b5cd4 100644 --- a/test/node/runtime-compat.node.test.ts +++ b/test/node/runtime-compat.node.test.ts @@ -53,7 +53,8 @@ describe('runtime compatibility (node)', () => { const client = new Facturapi('sk_test_123', { headers: { 'x-facturapi-client': 'MCP', - Authorization: 'Bearer ignored', + authorization: 'Bearer ignored', + 'content-type': 'text/plain', }, }) client.BASE_URL = 'https://api.test.local/v2' @@ -63,6 +64,9 @@ describe('runtime compatibility (node)', () => { 'Bearer sk_test_123', ) expect(getHeader(options?.headers, 'x-facturapi-client')).toBe('MCP') + expect(getHeader(options?.headers, 'Content-Type')).toBe( + 'application/json', + ) return new Response(JSON.stringify({ id: 'org_123' }), { status: 200, diff --git a/test/web/runtime-compat.web.test.ts b/test/web/runtime-compat.web.test.ts index 3ac66a9..289d9b3 100644 --- a/test/web/runtime-compat.web.test.ts +++ b/test/web/runtime-compat.web.test.ts @@ -66,7 +66,8 @@ describe('runtime compatibility (web simulation)', () => { const client = new Facturapi('sk_test_123', { headers: { 'x-facturapi-client': 'MCP', - Authorization: 'Bearer ignored', + authorization: 'Bearer ignored', + 'content-type': 'text/plain', }, }) client.BASE_URL = 'https://api.test.local/v2' @@ -76,6 +77,9 @@ describe('runtime compatibility (web simulation)', () => { 'Bearer sk_test_123', ) expect(getHeader(options?.headers, 'x-facturapi-client')).toBe('MCP') + expect(getHeader(options?.headers, 'Content-Type')).toBe( + 'application/json', + ) return { ok: true, @@ -261,6 +265,7 @@ describe('runtime compatibility (web simulation)', () => { const client = new Facturapi('sk_test_123', { headers: { 'x-facturapi-client': 'MCP', + 'content-type': 'text/plain', }, }) client.BASE_URL = 'https://api.test.local/v2' From b240a1ffc9c8f7608bd9316dd3cff1a402122d56 Mon Sep 17 00:00:00 2001 From: javorosas Date: Sat, 16 May 2026 19:51:53 +0200 Subject: [PATCH 3/3] test: handle Headers in node18 smoke --- test/compat/node18-compat.test.cjs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/test/compat/node18-compat.test.cjs b/test/compat/node18-compat.test.cjs index d4d192b..84642d6 100644 --- a/test/compat/node18-compat.test.cjs +++ b/test/compat/node18-compat.test.cjs @@ -14,6 +14,18 @@ function createClient() { return client; } +function getHeader(headers, name) { + if (!headers) return undefined; + if (headers instanceof Headers) return headers.get(name) || undefined; + if (Array.isArray(headers)) { + const match = headers.find( + ([key]) => key.toLowerCase() === name.toLowerCase(), + ); + return match && match[1]; + } + return headers[name] || headers[name.toLowerCase()]; +} + test.afterEach(() => { globalThis.fetch = ORIGINAL_FETCH; }); @@ -22,7 +34,10 @@ test('node18: uses bearer auth and parses json', async () => { const client = createClient(); globalThis.fetch = async (_url, options) => { - assert.equal(options.headers.Authorization, 'Bearer sk_test_123'); + assert.equal( + getHeader(options.headers, 'Authorization'), + 'Bearer sk_test_123', + ); return new Response(JSON.stringify({ id: 'inv_123' }), { status: 200, headers: { 'content-type': 'application/json' },