finansfatura
v0.4.0
Published
Node client for the Finansfatura e-invoice (e-Fatura / e-Arşiv) API
Maintainers
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 finansfaturaThe 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_numberStep 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); // -> ... QUEUEDThe 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 processVAT 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 dateSend 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 connectionredirectUrimust 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) andinvoice: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.
baseUrlis 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 frameworkLicense
MIT
