npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

finansfatura

v0.4.0

Published

Node client for the Finansfatura e-invoice (e-Fatura / e-Arşiv) API

Readme

finansfatura

Node client for the Finansfatura API — turn orders into sales, issue e-Fatura / e-Arşiv documents, and follow their status. Full API reference: apidocs.finansfatura.com.

Zero dependencies (native fetch, Node 18+). Ships with TypeScript types.

Install

npm install finansfatura

The flow

An integration is two steps, in this order:

1. createOrder()   POST /v1/integrations/orders     → transaction_id
2. issueInvoice()  POST /v1/invoicing/invoices/     → invoice_id
   orderStatus()   GET  /v1/integrations/…/status   → invoice_number

Step 2 is optional — if you only push sales, the taxpayer invoices them from the panel, one by one or in bulk. That is the smoothest start for most integrations.

Step 1 is not optional, and neither is the buyer inside it. The sale is what puts the order in the turnover report and the stock, and what keeps the order alive when invoicing fails (no credits, bad VKN, GİB down). The buyer is copied onto the sale as the document's billing recipient — no current account ("cari") is created — so title (or contact_name) is required: it names who the document is issued to.

The copy is deliberate: a document carries the recipient as they were when it was issued.

| buyer field | on the document | |---|---| | title — required, falls back to contact_name | Unvan / Ad Soyad | | tax_number (10) / tckn (11) | VKN/TCKN — one field; tax_number wins if both are sent | | tax_office | Vergi dairesi | | address | Adres | | email | E-posta — see below | | phone | Telefon |

Neither identity is required (your channel may not hold it), but send it when you have it: without a VKN we cannot look the recipient up at GİB and upgrade the document to e-Fatura. Send email too — on an e-Arşiv document GİB's mandatory delivery-type field is derived from it: ELEKTRONIK when there is an address, KAGIT when there is not.

Which makes the chain fixed: buyer → sale → document. transactionHeaderId is required when you do reach step 2; the only exception is a refund (invoiceTypeCode: "IADE"), which stays unattached so the sale is not counted twice.

Quickstart

import { FinansfaturaClient, buildEarsivPayload } from "finansfatura";

const ff = new FinansfaturaClient({ apiKey: process.env.FINANSFATURA_API_KEY }); // ff_live_...

// 1 — the sale. Prices KDV-INCLUSIVE, vat_rate as a percentage.
const sale = await ff.createOrder({
  provider: "ECOMSOFT",             // your brand; unknown values show as FINANSFATURA
  external_id: "ORD-2026-00184",    // your stable order id
  order_number: "184",
  payment_status: "PAID",
  currency: "TRY",
  total_price: 120.0,
  // Belgenin alıcısı: unvan/ad soyad, VKN/TCKN, vergi dairesi, adres, e-posta, telefon
  buyer: {
    title: "Ahmet Yılmaz",
    tckn: "11111111111",
    email: "[email protected]",
    phone: "5551112233",
    address: "Kadıköy / İstanbul",
  },
  lines: [{
    sku: "SKU-1042", title: "Kablosuz Kulaklık",
    quantity: 1, unit_price: 120.0, total_price: 120.0, vat_rate: 20,
  }],
});

// 2 — the invoice. Prices KDV-EXCLUSIVE, vat_rate as a ratio.
const payload = buildEarsivPayload(
  { vkn_tckn: "11111111111", title: "Ahmet Yılmaz" },
  [{ title: "Kablosuz Kulaklık", product_code: "SKU-1042", qty: 1, unit_price: 100.0, vat_rate: 0.20 }],
  { transactionHeaderId: sale.transaction_id },
);

const result = await ff.issueInvoice(payload, "ORD-2026-00184");
console.log(result.invoice_id, result.status); // -> ... QUEUED

The two endpoints disagree about VAT on purpose. The order body carries KDV-inclusive prices with a percentage (120, 20); the invoice body carries KDV-exclusive prices with a ratio (100, 0.20). This is the most common integration bug — the builders keep the invoice side honest, the order side is yours.

transactionHeaderId links the invoice to the sale — the builder throws without it, and so does the API. A document that hangs off no sale is invisible to the turnover report, the current account and stock.

Sandbox

import { SANDBOX_BASE_URL } from "finansfatura";

const ff = new FinansfaturaClient({ apiKey: "ff_test_...", baseUrl: SANDBOX_BASE_URL });

Sandbox and production are entirely separate systems — keys, OAuth clients and data never cross over.

About the builders

buildEarsivPayload computes totals from the lines (exact decimal, no float kuruş drift) and applies the API's exact field casing for you: the outer layer is snake_case (document_type, canonical) but everything inside canonical is PascalCase (Recipient, Lines, Totals, VKNorTCKN). A snake_case key inside canonical is silently ignored by the server, so let the builder handle it.

For exact money you may pass qty / unit_price / vat_rate as strings ("33.33") — numbers work too, the builder rounds half-even to the kuruş.

Idempotency

The second argument to issueInvoice is required and can be any unique string (use your order id). Retrying with the same key never double-issues and never charges credits twice. Likewise, resending the same external_id to createOrder never duplicates the sale — you get 200 with already_imported: true instead of 201. Both are what make retries safe.

Following the status

The invoice number is not in the issue response — the provider assigns it a moment later. Read it from the bulk status endpoint:

const { statuses } = await ff.orderStatus("ecomsoft", ["ORD-2026-00184", "ORD-2026-00185"]);
for (const s of statuses) console.log(s.external_id, s.invoice_status, s.invoice_number);

Up to 50 ids per call. Orders we never received are simply absent from the response, so match on external_id — don't trust the order. Statuses are NOT_INVOICED, QUEUED, ISSUED, ACCEPTED, REJECTED, CANCELLED; the last three are final.

Check once in the first minute after issuing, then every few minutes for the records that are not final yet. Polling faster does not make GİB answer sooner.

Reading & lifecycle

await ff.getInvoice(invoiceId);
await ff.listInvoices(1, 20);                    // page, page size
const pdf = await ff.download(invoiceId, "pdf"); // Uint8Array; or "html" / "xml"
await ff.cancel(invoiceId);                      // e-Arşiv outright; e-Fatura is a process

VAT exemption

A zero-VAT line cannot be invoiced without an exemption reason — GİB rejects it. The reason is document-level and lands only on the zero-VAT lines, so a mixed document never looks like the exemption covers the taxed lines too.

const payload = buildEarsivPayload(recipient, [
  { title: "Mal ihracatı", qty: 1, unit_price: 1000.0, vat_rate: 0 },
  { title: "Kargo", qty: 1, unit_price: 100.0, vat_rate: 0.2 },
], {
  transactionHeaderId: sale.transaction_id,
  exemptionCode: "301",                   // GİB 2xx (partial) / 3xx (full)
  exemptionReason: "11/1-a Mal ihracatı",
});

Both are required together: GİB will not take an empty cbc:TaxExemptionReason, and a code with no text declares nothing. On a sale, send invoice_exemption_code + invoice_exemption_reason instead — it is then stored on the sale and the invoice reads it from there.

Do not send the invoice type. The server derives it (zero-VAT + exemption → ISTISNA).

Scenario (e-Fatura only)

TEMELFATURA or TICARIFATURA. The difference is legal, not cosmetic: on a TEMEL invoice the recipient cannot answer and the document is final; on a TİCARİ one they may send KABUL/RED within 8 days. Default is TICARIFATURA; e-Arşiv has no choice.

buildEfaturaPayload(recipient, lines, "", {
  transactionHeaderId: sale.transaction_id,
  scenario: "TEMELFATURA",
});

Refunds

A refund is its own document (IADE) with its own idempotency key, deliberately not attached to the sale — attaching it would count the sale twice.

await ff.refund({
  external_id: "REF-2026-0007",       // your stable refund id
  order_external_id: "ORD-2026-00184", // the sale being refunded
  currency: "TRY",
  total_price: 120.0,
  lines: [/* same shape as the sale's lines */],
  buyer: {/* same recipient */},
});

Lines carry positive amounts; the document type, not the sign, says it is a refund. Prices are KDV-INCLUSIVE and vat_rate is a percentage, as in createOrder().

Refunding a foreign-currency sale uses the ORIGINAL sale's rate, not today's: leave exchange_rate out and we look it up from the sale we hold. Refunding a months-old sale at today's rate is a wrong declaration.

Issuing the refund document yourself

It needs the ORIGINAL invoice it refunds — GİB rejects a refund without that reference:

buildEarsivPayload(recipient, lines, {
  invoiceTypeCode: "IADE",            // or TEVKIFATIADE / YTBIADE
  returnInfo: { number: "FF32026000000123", issue_date: "2026-09-27" },
});

The date is sent as RFC 3339 for you; a bare 2026-09-27 would fail to parse server-side. This is also the one document where currency / exchangeRate matter — there is no sale to take them from.

Withholding and special tax base

Both are per line, and they are opposite situations — withholding splits who pays the VAT, a special tax base changes what the VAT is computed on:

buildEfaturaPayload(recipient, [
  { title: "Temizlik hizmeti", qty: 1, unit_price: 1000.0, vat_rate: 0.2,
    withholding_code: "612", withholding_name: "Temizlik hizmeti" },
  { title: "İkinci el araç", qty: 1, unit_price: 550000.0, vat_rate: 0.2,
    tax_base_amount: 50000.0, tax_base_code: "812",
    tax_base_reason: "İkinci el araç kâr marjı" },
], "", { transactionHeaderId: sale.transaction_id });

You do not send the withholding rate. Each GİB code carries a fixed legal rate and the server derives it from the code — 612 (cleaning) went from 7/10 to 9/10 in 2023. If the rate came from you, a GİB update would leave your integration filing wrong declarations for years.

8xx codes exist in both lists (withholding 801-825, special base 801-812) but are different UBL elements and different fields.

Exchange rates

You usually don't need to supply one. Leave exchange_rate off a foreign-currency sale (or send 0) and the server fills in the TCMB rate, then tells you what it used:

const sale = await ff.createOrder({
  external_id: "ORD-1", currency: "USD", // no exchange_rate
  buyer, lines,
});
sale.exchange_rate;        // 41.37
sale.exchange_rate_source; // "TCMB" — ours. "MANUAL" when you sent your own
sale.exchange_rate_date;   // "27.09.2026" — the bulletin's date

Send your own rate when you want yours instead of ours: it is used verbatim and never compared against TCMB. The bulletin is published on weekdays around 15:30 and not at weekends, so a Monday-morning document carries Friday's rate.

A rate is never invented: if the bulletin does not carry that currency the request fails with ERROR_EXCHANGE_RATE_REQUIRED.

Errors

Failed calls throw a typed error carrying .status, .body and .retryable:

| Class | HTTP | Meaning | Retry | |-------|------|---------|-------| | ValidationError | 400, 422 | bad body — .body.errors names the fields | ❌ | | AuthError | 401 | key/token missing, invalid, revoked or expired | ❌ | | InsufficientCredits | 402 | not enough credits (kontör) | ❌ | | ScopeError | 403 | missing scope, or endpoint closed to API keys | ❌ | | OnboardingRequired | 412 | the taxpayer's e-invoice setup is unfinished | ❌ | | RateLimitError | 429 | too many requests | ✅ | | ProviderError | 5xx | transient upstream / provider unreachable | ✅ | | FinansfaturaError | other | base class | — |

import { FinansfaturaError, OnboardingRequired } from "finansfatura";

try {
  await ff.issueInvoice(payload, `order-${order.id}`);
} catch (e) {
  if (e instanceof OnboardingRequired) {
    // The sale is safe. Tell the merchant to finish setup in the panel; the
    // pending sales can be invoiced later.
  } else if (e instanceof FinansfaturaError) {
    if (e.retryable) scheduleRetry(order.id);    // 1s, 2s, 4s, 8s …
    else console.error(`issue failed [${e.status}]`, e.body);
  } else throw e;
}

e-Fatura vs e-Arşiv

Send EARSIV and let the server correct it. When RecipientAlias is left empty (the default), we ask GİB about the recipient's VKN: registered taxpayers are upgraded to EFATURA with the mailbox alias resolved, everyone else stays EARSIV. You don't need to run the lookup yourself.

import { buildEfaturaPayload } from "finansfatura";

const payload = buildEfaturaPayload(
  { vkn_tckn: "1234567890", title: "Kurum A.Ş." },
  [/* lines */],
  "",                                   // alias: leave empty, the server resolves it
  { transactionHeaderId: sale.transaction_id },
);

Other document types (EIRSALIYE, ESMM, EMM, EADISYON) go through buildPayload(documentType, ...).

OAuth 2.0

API keys bind to one company. If your product serves many taxpayers, register an OAuth client ([email protected]) and drop the copy-paste step:

import { FinansfaturaClient, OAuth, generatePkce } from "finansfatura";

const oauth = new OAuth({ clientId, clientSecret, redirectUri: "https://app.example.com/ff/callback" });

// 1 — send the taxpayer to the consent screen
const { verifier, challenge } = generatePkce();   // keep `verifier` in the session
req.session.ffVerifier = verifier;
res.redirect(oauth.authorizeUrl({ codeChallenge: challenge, state: csrfToken }));

// 2 — the callback comes back with ?code=…
const token = await oauth.exchangeCode(req.query.code, { codeVerifier: req.session.ffVerifier });
await store(token.access_token, token.refresh_token, token.expires_in);

// 3 — use it
const ff = new FinansfaturaClient({ accessToken: token.access_token });
const fresh = await oauth.refresh(storedRefreshToken);  // store the NEW refresh token
await oauth.revoke(storedRefreshToken);                 // end the connection
  • redirectUri must match a registered address exactly; partial matches are rejected.
  • Every refresh invalidates the previous refresh token. Persist the newest one or the connection dies.
  • Scopes: invoice:read (status/reads) and invoice:write (sales, issuing, cancelling). Ask only for what you use.
  • Token and revoke URLs keep their trailing slash — the client handles it.

Notes

  • Seller identity (Issuer) is filled server-side from your company profile — don't send it. Make sure the profile VKN is set, or issuing returns 503.
  • Keep the API key server-side and encrypted; it acts on the taxpayer's company. Never ship it to a browser or mobile app.
  • baseUrl is the API host only (https://api.finansfatura.com) — paths are built by the client. Since 0.2.0 it no longer includes /v1/invoicing.

Changelog

See CHANGELOG.md.

Development

npm test   # node --test, no framework

License

MIT